mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-14 13:49:18 +02:00
feat(skills): support importing skills from github.com URLs (#2209)
This commit is contained in:
@@ -48,7 +48,8 @@
|
||||
"source_runtime_provider": "From {{provider}} runtime",
|
||||
"source_runtime_unknown": "From a runtime",
|
||||
"source_clawhub": "From ClawHub",
|
||||
"source_skills_sh": "From Skills.sh"
|
||||
"source_skills_sh": "From Skills.sh",
|
||||
"source_github": "From GitHub"
|
||||
},
|
||||
"detail": {
|
||||
"all_skills": "All skills",
|
||||
@@ -70,6 +71,7 @@
|
||||
"origin_runtime_unknown": "Local runtime",
|
||||
"origin_clawhub": "Imported · ClawHub",
|
||||
"origin_skills_sh": "Imported · Skills.sh",
|
||||
"origin_github": "Imported · GitHub",
|
||||
"origin_workspace": "Workspace",
|
||||
"updated_label": "Updated {{when}}",
|
||||
"by_creator": "by {{name}}"
|
||||
@@ -104,6 +106,7 @@
|
||||
"imported_runtime": "Imported from local runtime",
|
||||
"imported_clawhub": "Imported from ClawHub",
|
||||
"imported_skills_sh": "Imported from Skills.sh",
|
||||
"imported_github": "Imported from GitHub",
|
||||
"provider": "provider · {{provider}}"
|
||||
},
|
||||
"not_found": {
|
||||
@@ -189,6 +192,7 @@
|
||||
"importing": "Importing…",
|
||||
"importing_clawhub": "Importing from ClawHub…",
|
||||
"importing_skills_sh": "Importing from Skills.sh…",
|
||||
"importing_github": "Importing from GitHub…",
|
||||
"name_conflict_hint": " The imported skill's name already exists — delete the existing one before retrying.",
|
||||
"fallback_error": "Import failed",
|
||||
"cancel": "Cancel",
|
||||
|
||||
@@ -60,7 +60,8 @@
|
||||
"source_runtime_provider": "来自 {{provider}} 运行时",
|
||||
"source_runtime_unknown": "来自某个运行时",
|
||||
"source_clawhub": "来自 ClawHub",
|
||||
"source_skills_sh": "来自 Skills.sh"
|
||||
"source_skills_sh": "来自 Skills.sh",
|
||||
"source_github": "来自 GitHub"
|
||||
},
|
||||
"detail": {
|
||||
"all_skills": "全部 skill",
|
||||
@@ -82,6 +83,7 @@
|
||||
"origin_runtime_unknown": "本地运行时",
|
||||
"origin_clawhub": "导入自 · ClawHub",
|
||||
"origin_skills_sh": "导入自 · Skills.sh",
|
||||
"origin_github": "导入自 · GitHub",
|
||||
"origin_workspace": "工作区",
|
||||
"updated_label": "{{when}}更新",
|
||||
"by_creator": "由 {{name}}"
|
||||
@@ -116,6 +118,7 @@
|
||||
"imported_runtime": "从本地运行时导入",
|
||||
"imported_clawhub": "从 ClawHub 导入",
|
||||
"imported_skills_sh": "从 Skills.sh 导入",
|
||||
"imported_github": "从 GitHub 导入",
|
||||
"provider": "provider · {{provider}}"
|
||||
},
|
||||
"not_found": {
|
||||
@@ -201,6 +204,7 @@
|
||||
"importing": "导入中...",
|
||||
"importing_clawhub": "正在从 ClawHub 导入...",
|
||||
"importing_skills_sh": "正在从 Skills.sh 导入...",
|
||||
"importing_github": "正在从 GitHub 导入...",
|
||||
"name_conflict_hint": "导入的 skill 名称已存在——请先删除已有的再重试。",
|
||||
"fallback_error": "导入失败",
|
||||
"cancel": "取消",
|
||||
|
||||
@@ -241,12 +241,13 @@ function ManualForm({
|
||||
// URL import form
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type DetectedSource = "clawhub" | "skills.sh" | null;
|
||||
type DetectedSource = "clawhub" | "skills.sh" | "github" | null;
|
||||
|
||||
function detectUrlSource(url: string): DetectedSource {
|
||||
const u = url.trim().toLowerCase();
|
||||
if (u.includes("clawhub.ai")) return "clawhub";
|
||||
if (u.includes("skills.sh")) return "skills.sh";
|
||||
if (u.includes("github.com")) return "github";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -316,6 +317,7 @@ function UrlForm({
|
||||
if (!loading) return t(($) => $.create.url.import);
|
||||
if (source === "clawhub") return t(($) => $.create.url.importing_clawhub);
|
||||
if (source === "skills.sh") return t(($) => $.create.url.importing_skills_sh);
|
||||
if (source === "github") return t(($) => $.create.url.importing_github);
|
||||
return t(($) => $.create.url.importing);
|
||||
})();
|
||||
|
||||
@@ -350,7 +352,7 @@ function UrlForm({
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
{t(($) => $.create.url.supported_sources)}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<SourceCard
|
||||
label="ClawHub"
|
||||
exampleHost="clawhub.ai/owner/skill"
|
||||
@@ -363,6 +365,12 @@ function UrlForm({
|
||||
browseUrl="https://skills.sh"
|
||||
active={source === "skills.sh"}
|
||||
/>
|
||||
<SourceCard
|
||||
label="GitHub"
|
||||
exampleHost="github.com/owner/repo"
|
||||
browseUrl="https://github.com"
|
||||
active={source === "github"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -205,6 +205,9 @@ function SourceCell({
|
||||
} else if (origin.type === "skills_sh") {
|
||||
icon = <Download className="h-3 w-3 shrink-0" />;
|
||||
label = t(($) => $.table.source_skills_sh);
|
||||
} else if (origin.type === "github") {
|
||||
icon = <Download className="h-3 w-3 shrink-0" />;
|
||||
label = t(($) => $.table.source_github);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -199,7 +199,9 @@ function OriginSidebarCard({
|
||||
? t(($) => $.detail.origin_card.imported_runtime)
|
||||
: origin.type === "clawhub"
|
||||
? t(($) => $.detail.origin_card.imported_clawhub)
|
||||
: t(($) => $.detail.origin_card.imported_skills_sh);
|
||||
: origin.type === "github"
|
||||
? t(($) => $.detail.origin_card.imported_github)
|
||||
: t(($) => $.detail.origin_card.imported_skills_sh);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/30 p-3">
|
||||
@@ -542,6 +544,7 @@ export function SkillDetailPage({ skillId }: { skillId: string }) {
|
||||
}
|
||||
if (origin.type === "clawhub") return t(($) => $.detail.subline.origin_clawhub);
|
||||
if (origin.type === "skills_sh") return t(($) => $.detail.subline.origin_skills_sh);
|
||||
if (origin.type === "github") return t(($) => $.detail.subline.origin_github);
|
||||
return t(($) => $.detail.subline.origin_workspace);
|
||||
})();
|
||||
|
||||
|
||||
@@ -3,16 +3,11 @@ import type { Skill, SkillSummary } from "@multica/core/types";
|
||||
/**
|
||||
* Discriminated view over `Skill.config.origin` — the JSONB blob the backend
|
||||
* writes when a skill was imported from outside (local runtime, ClawHub,
|
||||
* Skills.sh). Manual creates have no origin, so we synthesize `{ type:
|
||||
* "manual" }` for them to keep the consumer code uniform.
|
||||
*
|
||||
* NOTE: the backend currently only writes `runtime_local` origins. URL
|
||||
* imports leave `config.origin` empty, so `clawhub`/`skills_sh` variants are
|
||||
* declared here for forward compatibility but should never be rendered in
|
||||
* the UI until the server fills them in.
|
||||
* Skills.sh, GitHub). Manual creates have no origin, so we synthesize
|
||||
* `{ type: "manual" }` for them to keep the consumer code uniform.
|
||||
*/
|
||||
export type OriginInfo = {
|
||||
type: "runtime_local" | "clawhub" | "skills_sh" | "manual";
|
||||
type: "runtime_local" | "clawhub" | "skills_sh" | "github" | "manual";
|
||||
provider?: string;
|
||||
runtime_id?: string;
|
||||
source_path?: string;
|
||||
@@ -26,6 +21,7 @@ export function readOrigin(skill: SkillSummary): OriginInfo {
|
||||
if (raw?.type === "runtime_local") return raw;
|
||||
if (raw?.type === "clawhub") return raw;
|
||||
if (raw?.type === "skills_sh") return raw;
|
||||
if (raw?.type === "github") return raw;
|
||||
return { type: "manual" };
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ var skillDeleteCmd = &cobra.Command{
|
||||
|
||||
var skillImportCmd = &cobra.Command{
|
||||
Use: "import",
|
||||
Short: "Import a skill from a URL (clawhub.ai or skills.sh)",
|
||||
Short: "Import a skill from a URL (clawhub.ai, skills.sh, or github.com)",
|
||||
RunE: runSkillImport,
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -441,12 +442,24 @@ type ImportSkillRequest struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -454,6 +467,31 @@ type importedFile struct {
|
||||
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.
|
||||
func (s *importedSkill) addFile(path, content string) error {
|
||||
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
|
||||
}
|
||||
|
||||
// --- ClawHub types ---
|
||||
|
||||
type clawhubGetSkillResponse struct {
|
||||
@@ -539,6 +577,7 @@ type importSource int
|
||||
const (
|
||||
sourceClawHub importSource = iota
|
||||
sourceSkillsSh
|
||||
sourceGitHub
|
||||
)
|
||||
|
||||
// detectImportSource determines the source from a URL.
|
||||
@@ -565,12 +604,14 @@ func detectImportSource(raw string) (importSource, string, error) {
|
||||
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)", host)
|
||||
return 0, "", fmt.Errorf("unsupported source: %s (supported: clawhub.ai, skills.sh, github.com)", host)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -654,6 +695,11 @@ func fetchFromClawHub(httpClient *http.Client, rawURL string) (*importedSkill, e
|
||||
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
|
||||
@@ -666,14 +712,26 @@ func fetchFromClawHub(httpClient *http.Client, rawURL string) (*importedSkill, e
|
||||
}
|
||||
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)
|
||||
} else {
|
||||
result.files = append(result.files, importedFile{path: fp, 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
|
||||
@@ -759,6 +817,13 @@ func fetchFromSkillsSh(httpClient *http.Client, rawURL string) (*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
|
||||
@@ -775,15 +840,15 @@ func fetchFromSkillsSh(httpClient *http.Client, rawURL string) (*importedSkill,
|
||||
|
||||
var entries []githubContentEntry
|
||||
if err := json.NewDecoder(dirResp.Body).Decode(&entries); err != nil {
|
||||
slog.Warn("skills.sh import: failed to decode top-level directory listing", "url", apiURL, "error", err)
|
||||
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("skills.sh import: collecting supporting files", "skill", skillName, "top_level_entries", len(entries))
|
||||
slog.Info("github import: collecting supporting files", "skill", skillName, "top_level_entries", len(entries))
|
||||
collectGitHubFiles(httpClient, entries, &allFiles, apiURL)
|
||||
slog.Info("skills.sh import: collected supporting files", "skill", skillName, "files", len(allFiles))
|
||||
slog.Info("github import: collected supporting files", "skill", skillName, "files", len(allFiles))
|
||||
|
||||
// 4. Download each file
|
||||
basePath := ""
|
||||
@@ -796,12 +861,17 @@ func fetchFromSkillsSh(httpClient *http.Client, rawURL string) (*importedSkill,
|
||||
}
|
||||
body, err := fetchRawFile(httpClient, entry.DownloadURL)
|
||||
if err != nil {
|
||||
slog.Warn("skills.sh import: file download failed", "path", entry.Path, "error", err)
|
||||
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)
|
||||
result.files = append(result.files, importedFile{path: relPath, content: string(body)})
|
||||
if err := result.addFile(relPath, string(body)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
@@ -836,7 +906,7 @@ func resolveGitHubSkillDirByName(httpClient *http.Client, owner, repo, defaultBr
|
||||
return "", nil, skillMdNotFoundError(owner, repo, skillName)
|
||||
}
|
||||
|
||||
slog.Warn("skills.sh import: repository tree listing truncated", "owner", owner, "repo", repo, "branch", defaultBranch)
|
||||
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
|
||||
}
|
||||
@@ -858,7 +928,7 @@ func collectGitHubFiles(httpClient *http.Client, entries []githubContentEntry, o
|
||||
if subURL == "" {
|
||||
parsed, err := url.Parse(parentURL)
|
||||
if err != nil {
|
||||
slog.Warn("skills.sh import: invalid parent directory url", "url", parentURL, "error", err)
|
||||
slog.Warn("github import: invalid parent directory url", "url", parentURL, "error", err)
|
||||
continue
|
||||
}
|
||||
parsed.Path = strings.TrimSuffix(parsed.Path, "/") + "/" + entry.Name
|
||||
@@ -874,13 +944,13 @@ func collectGitHubFiles(httpClient *http.Client, entries []githubContentEntry, o
|
||||
if err != nil {
|
||||
attrs = append(attrs, "error", err)
|
||||
}
|
||||
slog.Warn("skills.sh import: failed to list subdirectory", attrs...)
|
||||
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("skills.sh import: failed to decode subdirectory listing", "url", subURL, "error", err)
|
||||
slog.Warn("github import: failed to decode subdirectory listing", "url", subURL, "error", err)
|
||||
continue
|
||||
}
|
||||
subResp.Body.Close()
|
||||
@@ -895,7 +965,7 @@ func findSkillDirFromConventionalPrefixes(httpClient *http.Client, owner, repo,
|
||||
for _, prefix := range prefixes {
|
||||
paths, err := listGitHubSkillMdPaths(httpClient, owner, repo, prefix, defaultBranch)
|
||||
if err != nil {
|
||||
slog.Warn("skills.sh import: failed to list conventional skill prefix", "prefix", prefix, "error", err)
|
||||
slog.Warn("github import: failed to list conventional skill prefix", "prefix", prefix, "error", err)
|
||||
continue
|
||||
}
|
||||
skillPaths = append(skillPaths, paths...)
|
||||
@@ -949,7 +1019,7 @@ func collectGitHubSkillMdPaths(httpClient *http.Client, entries []githubContentE
|
||||
if subURL == "" {
|
||||
parsed, err := url.Parse(parentURL)
|
||||
if err != nil {
|
||||
slog.Warn("skills.sh import: invalid parent directory url", "url", parentURL, "error", err)
|
||||
slog.Warn("github import: invalid parent directory url", "url", parentURL, "error", err)
|
||||
continue
|
||||
}
|
||||
parsed.Path = strings.TrimSuffix(parsed.Path, "/") + "/" + entry.Name
|
||||
@@ -966,14 +1036,14 @@ func collectGitHubSkillMdPaths(httpClient *http.Client, entries []githubContentE
|
||||
if err != nil {
|
||||
attrs = append(attrs, "error", err)
|
||||
}
|
||||
slog.Warn("skills.sh import: failed to list skill metadata subdirectory", attrs...)
|
||||
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("skills.sh import: failed to decode skill metadata subdirectory", "url", subURL, "error", err)
|
||||
slog.Warn("github import: failed to decode skill metadata subdirectory", "url", subURL, "error", err)
|
||||
continue
|
||||
}
|
||||
subResp.Body.Close()
|
||||
@@ -1007,7 +1077,7 @@ func findMatchingSkillDirByFrontmatter(httpClient *http.Client, rawPrefix, skill
|
||||
for _, skillPath := range skillPaths {
|
||||
body, err := fetchRawFile(httpClient, buildRawGitHubURL(rawPrefix, skillPath))
|
||||
if err != nil {
|
||||
slog.Warn("skills.sh import: fallback SKILL.md fetch failed", "path", skillPath, "error", err)
|
||||
slog.Warn("github import: fallback SKILL.md fetch failed", "path", skillPath, "error", err)
|
||||
continue
|
||||
}
|
||||
name, _ := parseSkillFrontmatter(string(body))
|
||||
@@ -1080,9 +1150,272 @@ func parseSkillFrontmatter(content string) (name, description string) {
|
||||
return name, description
|
||||
}
|
||||
|
||||
// --- GitHub import ---
|
||||
|
||||
// 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))
|
||||
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 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
|
||||
}
|
||||
}
|
||||
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")
|
||||
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
|
||||
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 := 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 := httpClient.Get(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. Limit 1MB.
|
||||
// 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 {
|
||||
@@ -1092,7 +1425,26 @@ func fetchRawFile(httpClient *http.Client, fileURL string) ([]byte, error) {
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
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 {
|
||||
@@ -1165,6 +1517,8 @@ func (h *Handler) ImportSkill(w http.ResponseWriter, r *http.Request) {
|
||||
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())
|
||||
@@ -1182,13 +1536,20 @@ func (h *Handler) ImportSkill(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
resp, err := h.createSkillWithFiles(r.Context(), skillCreateInput{
|
||||
WorkspaceID: workspaceUUID,
|
||||
CreatorID: creatorUUID,
|
||||
Name: imported.name,
|
||||
Description: imported.description,
|
||||
Content: imported.content,
|
||||
Config: map[string]any{},
|
||||
Config: config,
|
||||
Files: files,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -221,7 +221,7 @@ func TestFetchFromSkillsSh_LogsSubdirectoryFailures(t *testing.T) {
|
||||
}
|
||||
|
||||
logOutput := logs.String()
|
||||
if !strings.Contains(logOutput, "skills.sh import: failed to list subdirectory") {
|
||||
if !strings.Contains(logOutput, "github import: failed to list subdirectory") {
|
||||
t.Fatalf("expected warning log, got %q", logOutput)
|
||||
}
|
||||
if !strings.Contains(logOutput, "status=404") {
|
||||
@@ -540,6 +540,508 @@ func TestFetchFromSkillsSh_AnthropicPptxIntegration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- GitHub source tests ---
|
||||
|
||||
func TestParseGitHubURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
want githubSpec
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "repo root",
|
||||
url: "https://github.com/acme/skill",
|
||||
want: githubSpec{owner: "acme", repo: "skill"},
|
||||
},
|
||||
{
|
||||
name: "repo root with .git suffix",
|
||||
url: "https://github.com/acme/skill.git",
|
||||
want: githubSpec{owner: "acme", repo: "skill"},
|
||||
},
|
||||
{
|
||||
name: "tree URL with directory",
|
||||
url: "https://github.com/anthropics/skills/tree/main/document-skills/pptx",
|
||||
want: githubSpec{owner: "anthropics", repo: "skills", ref: "main", skillDir: "document-skills/pptx"},
|
||||
},
|
||||
{
|
||||
name: "tree URL ref only",
|
||||
url: "https://github.com/anthropics/skills/tree/main",
|
||||
want: githubSpec{owner: "anthropics", repo: "skills", ref: "main"},
|
||||
},
|
||||
{
|
||||
name: "blob URL pointing at SKILL.md",
|
||||
url: "https://github.com/acme/skills/blob/main/skills/foo/SKILL.md",
|
||||
want: githubSpec{owner: "acme", repo: "skills", ref: "main", skillDir: "skills/foo"},
|
||||
},
|
||||
{
|
||||
name: "blob URL with URL-escaped path segment",
|
||||
url: "https://github.com/acme/skills/blob/main/my%20dir/SKILL.md",
|
||||
want: githubSpec{owner: "acme", repo: "skills", ref: "main", skillDir: "my dir"},
|
||||
},
|
||||
{
|
||||
name: "blob URL not pointing at SKILL.md",
|
||||
url: "https://github.com/acme/skills/blob/main/skills/foo/README.md",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing repo",
|
||||
url: "https://github.com/acme",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "unsupported segment",
|
||||
url: "https://github.com/acme/skills/issues/1",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "tree URL missing ref",
|
||||
url: "https://github.com/acme/skills/tree/",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseGitHubURL(tc.url)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got %+v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parseGitHubURL: %v", err)
|
||||
}
|
||||
if got.owner != tc.want.owner || got.repo != tc.want.repo ||
|
||||
got.ref != tc.want.ref || got.skillDir != tc.want.skillDir {
|
||||
t.Fatalf("got %+v, want %+v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectImportSource_RecognizesGitHub(t *testing.T) {
|
||||
src, _, err := detectImportSource("https://github.com/acme/skill")
|
||||
if err != nil {
|
||||
t.Fatalf("detectImportSource: %v", err)
|
||||
}
|
||||
if src != sourceGitHub {
|
||||
t.Fatalf("source = %v, want sourceGitHub", src)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchFromGitHub_TreeURLImportsSkillDirectory(t *testing.T) {
|
||||
client, requests := newGitHubFixtureClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Header.Get("X-Test-Original-Host") {
|
||||
case "api.github.com":
|
||||
switch r.URL.Path {
|
||||
case "/repos/anthropics/skills/commits/main":
|
||||
w.Write([]byte("deadbeef"))
|
||||
case "/repos/anthropics/skills/contents/document-skills/pptx":
|
||||
if got := r.URL.Query().Get("ref"); got != "main" {
|
||||
t.Fatalf("contents ref = %q, want main", got)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, []githubContentEntry{
|
||||
{
|
||||
Name: "editing.md",
|
||||
Path: "document-skills/pptx/editing.md",
|
||||
Type: "file",
|
||||
DownloadURL: "https://raw.githubusercontent.com/anthropics/skills/main/document-skills/pptx/editing.md",
|
||||
},
|
||||
{
|
||||
Name: "scripts",
|
||||
Path: "document-skills/pptx/scripts",
|
||||
Type: "dir",
|
||||
URL: "https://api.github.com/repos/anthropics/skills/contents/document-skills/pptx/scripts?ref=main",
|
||||
},
|
||||
})
|
||||
case "/repos/anthropics/skills/contents/document-skills/pptx/scripts":
|
||||
writeJSON(w, http.StatusOK, []githubContentEntry{
|
||||
{
|
||||
Name: "add_slide.py",
|
||||
Path: "document-skills/pptx/scripts/add_slide.py",
|
||||
Type: "file",
|
||||
DownloadURL: "https://raw.githubusercontent.com/anthropics/skills/main/document-skills/pptx/scripts/add_slide.py",
|
||||
},
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
case "raw.githubusercontent.com":
|
||||
switch r.URL.Path {
|
||||
case "/anthropics/skills/main/document-skills/pptx/SKILL.md":
|
||||
w.Write([]byte("---\nname: pptx\ndescription: presentation tools\n---\nbody"))
|
||||
case "/anthropics/skills/main/document-skills/pptx/editing.md":
|
||||
w.Write([]byte("editing"))
|
||||
case "/anthropics/skills/main/document-skills/pptx/scripts/add_slide.py":
|
||||
w.Write([]byte("print('slide')"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
|
||||
result, err := fetchFromGitHub(client, "https://github.com/anthropics/skills/tree/main/document-skills/pptx")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchFromGitHub: %v", err)
|
||||
}
|
||||
if result.name != "pptx" {
|
||||
t.Fatalf("name = %q, want pptx", result.name)
|
||||
}
|
||||
if result.description != "presentation tools" {
|
||||
t.Fatalf("description = %q, want presentation tools", result.description)
|
||||
}
|
||||
gotPaths := importedFilePaths(result.files)
|
||||
wantPaths := []string{"editing.md", "scripts/add_slide.py"}
|
||||
if !equalStrings(gotPaths, wantPaths) {
|
||||
t.Fatalf("files = %v (must be relative to skill dir), want %v", gotPaths, wantPaths)
|
||||
}
|
||||
// Verify the skill-relative path scheme: we never want supporting files
|
||||
// to keep the in-repo prefix (document-skills/pptx/...).
|
||||
for _, f := range result.files {
|
||||
if strings.HasPrefix(f.path, "document-skills/") {
|
||||
t.Fatalf("supporting file %q still carries skillDir prefix", f.path)
|
||||
}
|
||||
}
|
||||
origin := result.origin
|
||||
if origin == nil || origin["type"] != "github" {
|
||||
t.Fatalf("origin = %v, want type=github", origin)
|
||||
}
|
||||
if origin["ref"] != "main" || origin["path"] != "document-skills/pptx" {
|
||||
t.Fatalf("origin ref/path mismatch: %v", origin)
|
||||
}
|
||||
if !containsString(*requests, "api.github.com /repos/anthropics/skills/contents/document-skills/pptx?ref=main") {
|
||||
t.Fatalf("expected contents listing, got %v", *requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchFromGitHub_RepoRootResolvesDefaultBranch(t *testing.T) {
|
||||
client, requests := newGitHubFixtureClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Header.Get("X-Test-Original-Host") {
|
||||
case "api.github.com":
|
||||
switch r.URL.Path {
|
||||
case "/repos/alice/single-skill":
|
||||
writeJSON(w, http.StatusOK, map[string]any{"default_branch": "master"})
|
||||
case "/repos/alice/single-skill/contents":
|
||||
if got := r.URL.Query().Get("ref"); got != "master" {
|
||||
t.Fatalf("contents ref = %q, want master", got)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, []githubContentEntry{
|
||||
{
|
||||
Name: "README.md",
|
||||
Path: "README.md",
|
||||
Type: "file",
|
||||
DownloadURL: "https://raw.githubusercontent.com/alice/single-skill/master/README.md",
|
||||
},
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
case "raw.githubusercontent.com":
|
||||
switch r.URL.Path {
|
||||
case "/alice/single-skill/master/SKILL.md":
|
||||
w.Write([]byte("---\nname: single-skill\n---\nbody"))
|
||||
case "/alice/single-skill/master/README.md":
|
||||
w.Write([]byte("readme"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
|
||||
result, err := fetchFromGitHub(client, "https://github.com/alice/single-skill")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchFromGitHub: %v", err)
|
||||
}
|
||||
if result.name != "single-skill" {
|
||||
t.Fatalf("name = %q, want single-skill", result.name)
|
||||
}
|
||||
gotPaths := importedFilePaths(result.files)
|
||||
if !equalStrings(gotPaths, []string{"README.md"}) {
|
||||
t.Fatalf("files = %v", gotPaths)
|
||||
}
|
||||
if !containsString(*requests, "api.github.com /repos/alice/single-skill") {
|
||||
t.Fatalf("expected default-branch lookup, got %v", *requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchFromGitHub_RepoRootMissingSKILLmdReturnsActionableError(t *testing.T) {
|
||||
client, _ := newGitHubFixtureClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Header.Get("X-Test-Original-Host") {
|
||||
case "api.github.com":
|
||||
if r.URL.Path == "/repos/alice/multi" {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"default_branch": "main"})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
case "raw.githubusercontent.com":
|
||||
http.NotFound(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
|
||||
_, err := fetchFromGitHub(client, "https://github.com/alice/multi")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing root SKILL.md")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "tree/main/<skill-dir>") && !strings.Contains(err.Error(), "tree/main") {
|
||||
t.Fatalf("error should hint at /tree/{ref}/<skill-dir>, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchFromGitHub_BlobURLImportsSpecificSkill(t *testing.T) {
|
||||
client, _ := newGitHubFixtureClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Header.Get("X-Test-Original-Host") {
|
||||
case "api.github.com":
|
||||
switch r.URL.Path {
|
||||
case "/repos/acme/skills/commits/main":
|
||||
w.Write([]byte("deadbeef"))
|
||||
case "/repos/acme/skills/contents/skills/foo":
|
||||
writeJSON(w, http.StatusOK, []githubContentEntry{})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
case "raw.githubusercontent.com":
|
||||
if r.URL.Path == "/acme/skills/main/skills/foo/SKILL.md" {
|
||||
w.Write([]byte("---\nname: foo\n---\nbody"))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
|
||||
result, err := fetchFromGitHub(client, "https://github.com/acme/skills/blob/main/skills/foo/SKILL.md")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchFromGitHub: %v", err)
|
||||
}
|
||||
if result.name != "foo" {
|
||||
t.Fatalf("name = %q, want foo", result.name)
|
||||
}
|
||||
if result.origin["path"] != "skills/foo" {
|
||||
t.Fatalf("origin path = %v, want skills/foo", result.origin["path"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bundle / file size cap tests ---
|
||||
|
||||
func TestFetchRawFile_ReturnsErrorOnOversizedFile(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write(bytes.Repeat([]byte("a"), maxImportFileSize+1024))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
_, err := fetchRawFile(&http.Client{}, server.URL+"/big.bin")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for oversized file, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "byte limit") {
|
||||
t.Fatalf("error = %q, want byte limit message", err.Error())
|
||||
}
|
||||
if !isCapError(err) {
|
||||
t.Fatalf("error %q must be classified as a cap error so callers fail-fast", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportedSkill_AddFileEnforcesBundleLimits(t *testing.T) {
|
||||
t.Run("file count", func(t *testing.T) {
|
||||
s := &importedSkill{}
|
||||
for i := 0; i < maxImportFileCount; i++ {
|
||||
if err := s.addFile("f", "x"); err != nil {
|
||||
t.Fatalf("addFile %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
err := s.addFile("overflow", "x")
|
||||
if err == nil {
|
||||
t.Fatal("expected file count cap error")
|
||||
}
|
||||
if !isCapError(err) {
|
||||
t.Fatalf("error %q must be a cap error", err.Error())
|
||||
}
|
||||
})
|
||||
t.Run("total bytes", func(t *testing.T) {
|
||||
s := &importedSkill{}
|
||||
big := strings.Repeat("y", maxImportTotalSize)
|
||||
if err := s.addFile("a", big); err != nil {
|
||||
t.Fatalf("addFile at cap: %v", err)
|
||||
}
|
||||
err := s.addFile("b", "x")
|
||||
if err == nil {
|
||||
t.Fatal("expected total bytes cap error")
|
||||
}
|
||||
if !isCapError(err) {
|
||||
t.Fatalf("error %q must be a cap error", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// fetchFromGitHub must FAIL the import (not just log+continue) when a
|
||||
// supporting file exceeds the per-file cap — silently dropping the file
|
||||
// would leave a skill bundle that looks valid to the user but is missing
|
||||
// content.
|
||||
func TestFetchFromGitHub_OversizedSupportingFileFailsImport(t *testing.T) {
|
||||
client, _ := newGitHubFixtureClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Header.Get("X-Test-Original-Host") {
|
||||
case "api.github.com":
|
||||
switch r.URL.Path {
|
||||
case "/repos/acme/skills/commits/main":
|
||||
w.Write([]byte("deadbeef"))
|
||||
case "/repos/acme/skills/contents/foo":
|
||||
writeJSON(w, http.StatusOK, []githubContentEntry{
|
||||
{
|
||||
Name: "huge.bin",
|
||||
Path: "foo/huge.bin",
|
||||
Type: "file",
|
||||
DownloadURL: "https://raw.githubusercontent.com/acme/skills/main/foo/huge.bin",
|
||||
},
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
case "raw.githubusercontent.com":
|
||||
switch r.URL.Path {
|
||||
case "/acme/skills/main/foo/SKILL.md":
|
||||
w.Write([]byte("---\nname: foo\n---\nbody"))
|
||||
case "/acme/skills/main/foo/huge.bin":
|
||||
w.Write(bytes.Repeat([]byte("z"), maxImportFileSize+512))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
_, err := fetchFromGitHub(client, "https://github.com/acme/skills/tree/main/foo")
|
||||
if err == nil {
|
||||
t.Fatal("expected oversized supporting file to fail the whole import")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "huge.bin") || !strings.Contains(err.Error(), "byte limit") {
|
||||
t.Fatalf("error %q should name the file and the cap", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// fetchFromSkillsSh has the same supporting-file loop and must also fail
|
||||
// (not just warn) when one of those files exceeds the cap.
|
||||
func TestFetchFromSkillsSh_OversizedSupportingFileFailsImport(t *testing.T) {
|
||||
client, _ := newGitHubFixtureClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Header.Get("X-Test-Original-Host") {
|
||||
case "api.github.com":
|
||||
switch r.URL.Path {
|
||||
case "/repos/acme/skills":
|
||||
writeJSON(w, http.StatusOK, map[string]any{"default_branch": "main"})
|
||||
case "/repos/acme/skills/contents/skills/foo":
|
||||
writeJSON(w, http.StatusOK, []githubContentEntry{
|
||||
{
|
||||
Name: "huge.bin",
|
||||
Path: "skills/foo/huge.bin",
|
||||
Type: "file",
|
||||
DownloadURL: "https://raw.githubusercontent.com/acme/skills/main/skills/foo/huge.bin",
|
||||
},
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
case "raw.githubusercontent.com":
|
||||
switch r.URL.Path {
|
||||
case "/acme/skills/main/skills/foo/SKILL.md":
|
||||
w.Write([]byte("---\nname: foo\n---\nbody"))
|
||||
case "/acme/skills/main/skills/foo/huge.bin":
|
||||
w.Write(bytes.Repeat([]byte("z"), maxImportFileSize+512))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
_, err := fetchFromSkillsSh(client, "https://skills.sh/acme/skills/foo")
|
||||
if err == nil {
|
||||
t.Fatal("expected oversized supporting file to fail the whole import")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "huge.bin") {
|
||||
t.Fatalf("error %q should name the offending file", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Slash-bearing refs (e.g. release/v2) are now resolved against the API
|
||||
// instead of being silently parsed as ref="release", path="v2/...". The
|
||||
// resolver must walk longest→shortest and pick the prefix the API
|
||||
// confirms exists.
|
||||
func TestFetchFromGitHub_ResolvesSlashRefAgainstAPI(t *testing.T) {
|
||||
client, requests := newGitHubFixtureClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Header.Get("X-Test-Original-Host") {
|
||||
case "api.github.com":
|
||||
switch r.URL.Path {
|
||||
case "/repos/acme/skills/commits/release/v2/skills/foo",
|
||||
"/repos/acme/skills/commits/release/v2/skills":
|
||||
http.NotFound(w, r)
|
||||
case "/repos/acme/skills/commits/release/v2":
|
||||
w.Write([]byte("deadbeef"))
|
||||
case "/repos/acme/skills/contents/skills/foo":
|
||||
if got := r.URL.Query().Get("ref"); got != "release/v2" {
|
||||
t.Fatalf("contents called with ref=%q, want release/v2", got)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, []githubContentEntry{})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
case "raw.githubusercontent.com":
|
||||
switch r.URL.Path {
|
||||
case "/acme/skills/release/v2/skills/foo/SKILL.md":
|
||||
w.Write([]byte("---\nname: foo\n---\nbody"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
result, err := fetchFromGitHub(client, "https://github.com/acme/skills/tree/release/v2/skills/foo")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchFromGitHub: %v", err)
|
||||
}
|
||||
if result.origin["ref"] != "release/v2" {
|
||||
t.Fatalf("origin ref = %v, want release/v2", result.origin["ref"])
|
||||
}
|
||||
if result.origin["path"] != "skills/foo" {
|
||||
t.Fatalf("origin path = %v, want skills/foo", result.origin["path"])
|
||||
}
|
||||
// Sanity-check that the resolver actually probed in the expected order.
|
||||
if !containsString(*requests, "api.github.com /repos/acme/skills/commits/release/v2/skills/foo") {
|
||||
t.Fatalf("resolver should probe longest prefix first, requests=%v", *requests)
|
||||
}
|
||||
}
|
||||
|
||||
// When none of the candidate refs resolve, fail with a clear error that
|
||||
// names what was tried — do not silently fall back to using the first
|
||||
// segment as the ref (the previous behavior, which would import the wrong
|
||||
// branch / wrong path).
|
||||
func TestFetchFromGitHub_UnresolvableRefFailsLoudly(t *testing.T) {
|
||||
client, _ := newGitHubFixtureClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Header.Get("X-Test-Original-Host") {
|
||||
case "api.github.com":
|
||||
http.NotFound(w, r)
|
||||
case "raw.githubusercontent.com":
|
||||
t.Fatalf("must not hit raw.githubusercontent.com when ref unresolved: %s", r.URL.Path)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
_, err := fetchFromGitHub(client, "https://github.com/acme/skills/tree/nope/skills/foo")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no candidate ref resolves")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "could not resolve ref") {
|
||||
t.Fatalf("error %q should mention ref resolution failure", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
type rewriteGitHubTransport struct {
|
||||
target *url.URL
|
||||
base http.RoundTripper
|
||||
|
||||
Reference in New Issue
Block a user