From c13a338abe7215dc76392b35858509aa6b71887e Mon Sep 17 00:00:00 2001 From: Paperclip Agent Date: Sun, 29 Mar 2026 04:59:32 +0300 Subject: [PATCH] Align skillget-manager with registry compiled-core contract - Parse GET /skills/{name} versions as map (yanked, semver keys) - Resolve unpinned installs to highest non-yanked semver - Send RegistryReadBearer on API GET and archive fetch when set - Add docs/REGISTRY_CLIENT_CONTRACT.md; depend on golang.org/x/mod/semver Co-Authored-By: Paperclip Made-with: Cursor --- CHANGELOG.md | 12 ++-- README.md | 5 +- client.go | 3 + config.go | 10 ++++ docs/REGISTRY_CLIENT_CONTRACT.md | 52 ++++++++++++++++ download.go | 3 + download_test.go | 100 +++++++++++++++++++++++++++++-- go.mod | 6 +- go.sum | 2 + registry_errors.go | 2 +- resolve.go | 45 +++++++------- resolve_integration_test.go | 42 +++++-------- types.go | 54 ++++++++++------- 13 files changed, 253 insertions(+), 83 deletions(-) create mode 100644 docs/REGISTRY_CLIENT_CONTRACT.md create mode 100644 go.sum diff --git a/CHANGELOG.md b/CHANGELOG.md index 423dcf5..36f9a42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.1.4 — 2026-03-29 + +- **Breaking (контракт JSON):** `SkillDetail` приведён к канону `GET /skills/{name}` из [registry-api.md](https://github.com/getskillpack/registry/blob/main/docs/registry-api.md): поле `versions` — объект `map[version]`, флаг **`yanked`**, без вымышленного `latest_version` на детали скилла. +- `ResolveInstallTarget` без pin: выбор **максимальной semver** среди неснятых версий (как `latestNonYanked` в reference store). +- `FetchJSON` и загрузка архива: опциональный `Authorization: Bearer` из `SKILLGET_REGISTRY_READ_TOKEN` или fallback на токен записи (`RegistryReadBearer`). +- Документ для board/интеграций: [docs/REGISTRY_CLIENT_CONTRACT.md](docs/REGISTRY_CLIENT_CONTRACT.md). +- Зависимость: `golang.org/x/mod/semver` для сортировки версий. + ## 0.1.3 — 2026-03-28 - `DownloadSkillArchive`: подсказки при сетевых сбоях и при ответах **401 / 403 / 404 / 429 / 503** от хоста архива (URL может отличаться от базы реестра). @@ -27,7 +35,3 @@ - Сообщения об ошибках HTTP к реестру с краткими подсказками (как в TS `registry.ts`). - Документация: `SECURITY.md`, ссылки на лицензию и процесс безопасности в README. -## Unreleased - -- README: явно описаны подсказки в ошибках для запросов к реестру и для скачивания архива. - diff --git a/README.md b/README.md index a5f694a..af56a4b 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,15 @@ CLI ([cli](https://github.com/getskillpack/cli)) остаётся тонкой - `RegistryBaseURL` / `RegistryConfigSource` — выбор базы API из env (`SKILLGET_REGISTRY_URL`, legacy `SKPKG_REGISTRY_URL`). - `RegistryToken` — bearer для записи в реестр из `SKILLGET_REGISTRY_TOKEN` или `SKILLGET_TOKEN`. +- `RegistryReadBearer` — bearer для чтения (`SKILLGET_REGISTRY_READ_TOKEN` или, если пусто, тот же токен, что и для записи): добавляется к `GET` API и к загрузке `archive_url`, когда токен задан. - `FetchJSON` — JSON GET к реестру (через `context.Context`); типичные ответы реестра сопровождаются короткими подсказками (сеть, токен, 404, 409, 429 и др.). - `ReadSkillsLock` / `WriteSkillsLock` — файл `skills.lock`. - `SearchSkills` — список/поиск скиллов (опции `Query`, `Author`, `Limit`, `Offset`). -- `ParseNameVersion` / `ResolveInstallTarget` — разрешение версии и метаданные архива (для «latest» поле `latest_version`, если оно не помечено как yanked в списке версий; иначе первая неснятая версия). +- `ParseNameVersion` / `ResolveInstallTarget` — разрешение версии и метаданные архива; для «latest» без pin — **наибольшая semver** среди записей `versions` в ответе `GET /skills/{name}`, исключая `yanked: true` (как в reference registry). - `DownloadSkillArchive` — скачивание tarball, проверка `checksum` вида `sha256:` при наличии, обновление lockfile; при сбоях HTTP к **archive URL** (часто отдельно от базы реестра) — свои подсказки (401–429, 503, сеть). - `PublishSkill` — multipart POST `/skills` (manifest + archive), как у TS-клиента в `skpkg-cli`. -Контракт HTTP API реестра: [registry/API.md](https://github.com/getskillpack/registry/blob/main/API.md). +Контракт HTTP API реестра: [registry/API.md](https://github.com/getskillpack/registry/blob/main/API.md). Поведение этой библиотеки как клиента: [docs/REGISTRY_CLIENT_CONTRACT.md](docs/REGISTRY_CLIENT_CONTRACT.md). ## Разработка diff --git a/client.go b/client.go index 9150930..a2a68b6 100644 --- a/client.go +++ b/client.go @@ -28,6 +28,9 @@ func FetchJSON(ctx context.Context, path string, out any) error { return err } req.Header.Set("Accept", "application/json") + if t := RegistryReadBearer(); t != "" { + req.Header.Set("Authorization", "Bearer "+t) + } res, err := HTTPClient.Do(req) if err != nil { diff --git a/config.go b/config.go index e94052d..754a0d3 100644 --- a/config.go +++ b/config.go @@ -54,3 +54,13 @@ func RegistryToken() string { } return trimToken(os.Getenv("SKILLGET_TOKEN")) } + +// RegistryReadBearer returns the Bearer token for GET /api/v1/* and archive downloads +// when the registry operator enabled REGISTRY_READ_TOKEN. SKILLGET_REGISTRY_READ_TOKEN +// wins when set; otherwise falls back to RegistryToken() so one secret can cover read+write. +func RegistryReadBearer() string { + if t := trimToken(os.Getenv("SKILLGET_REGISTRY_READ_TOKEN")); t != "" { + return t + } + return RegistryToken() +} diff --git a/docs/REGISTRY_CLIENT_CONTRACT.md b/docs/REGISTRY_CLIENT_CONTRACT.md new file mode 100644 index 0000000..007ad01 --- /dev/null +++ b/docs/REGISTRY_CLIENT_CONTRACT.md @@ -0,0 +1,52 @@ +# Контракт клиента skillget-manager ↔ registry HTTP API + +Канон спецификации сервера: [`docs/registry-api.md`](https://github.com/getskillpack/registry/blob/main/docs/registry-api.md) (раздел **Compiled core**). Этот документ фиксирует поведение библиотеки `github.com/getskillpack/skillget-manager` как **скомпилированного клиента**. + +## Переменные окружения + +| Переменная | Назначение | +|------------|------------| +| `SKILLGET_REGISTRY_URL` | Базовый URL API **без** завершающего `/`: `{origin}/api/v1`. Имеет приоритет. | +| `SKPKG_REGISTRY_URL` | Устаревший алиас для базы API. | +| `SKILLGET_REGISTRY_READ_TOKEN` | Bearer для **GET** к `/api/v1/*` и для **GET** по `archive_url`, если оператор реестра включил `REGISTRY_READ_TOKEN`. | +| `SKILLGET_REGISTRY_TOKEN` | Bearer для **записи** (`POST /skills`, `DELETE …/versions/…`). | +| `SKILLGET_TOKEN` | Устаревший алиас для токена записи. | + +Если задан только `SKILLGET_REGISTRY_READ_TOKEN`, анонимные запросы к API не выполняются: чтение идёт с этим Bearer. Если `SKILLGET_REGISTRY_READ_TOKEN` пуст, для чтения используется тот же токен, что и для записи (`SKILLGET_REGISTRY_TOKEN` / `SKILLGET_TOKEN`), если он задан — это упрощает сценарий «один секрет на приватный реестр». + +Публичный реестр по умолчанию: `https://registry.skpkg.org/api/v1` (без обязательных токенов для чтения). + +## HTTP: маршруты и коды + +Пути ниже относительны к базе из `SKILLGET_REGISTRY_URL`. + +| Действие в библиотеке | Метод и путь | Успех | Ошибки (ожидания клиента) | +|------------------------|--------------|-------|---------------------------| +| `SearchSkills` | `GET /skills?…` | `200` + JSON | `401` при read-token на сервере | +| `ResolveInstallTarget` (без pin) | `GET /skills/{name}` | `200` + JSON detail | `404`, `401` | +| `ResolveInstallTarget` (все случаи) | `GET /skills/{name}/versions/{version}` | `200` + JSON version | `404`, **`410`** после yank, `401` | +| `PublishSkill` | `POST /skills` (multipart `manifest` + `archive`) | **`201`** пустое тело | `400`, `401`, `409`, `503` без write token | +| (сервер) yank | `DELETE /skills/{name}/versions/{version}` | **`204`** | `401`, `404` | + +Тела ошибок reference-сервера — короткий `text/plain` (не JSON). Сообщения об ошибках в Go дополняются подсказками (`registry_errors.go`). + +## JSON: формы ответов + +- **Список** `GET /skills` — объект с полями `data[]`, `meta` (`total`, `limit`, `offset`). Элементы `data` содержат как минимум `name`, `latest_version`, `created_at`, `description`, `author` (как в спецификации). +- **Деталь скилла** `GET /skills/{name}` — поля `name`, `description`, `author`, `created_at`, **`versions`** — объект, ключи = semver-строки версий, значения: `manifest`, `checksum` (`sha256:` + 64 hex), `archive_url`, `published_at`, `yanked`. +- **Версия** `GET /skills/{name}/versions/{version}` — `name`, `version`, `manifest`, `archive_url`, `checksum`. + +Библиотека **не** использует несуществующее в каноне поле `latest_version` на ответе `GET /skills/{name}`: для установки без pin выбирается **наибольшая по semver** неснятая (`yanked: false`) версия из `versions`, в духе логики `latestNonYanked` в reference store. + +## Публикация и архив + +- `PublishSkill`: `multipart/form-data`, поля `manifest` (JSON-строка) и `archive` (файл `.tar.gz`), заголовок `Authorization: Bearer `. +- `DownloadSkillArchive`: GET по абсолютному `archive_url` из ответа версии; при непустом `RegistryReadBearer()` добавляется тот же `Authorization`, что и к API (для приватных `/downloads/*`). + +## Проверка целостности + +Если в метаданных версии присутствует `checksum` в формате `sha256:<64 hex>`, после скачивания выполняется сверка SHA-256 с содержимым файла. + +## Соответствие ENGINEERING_REQUIREMENTS_SKPKG.md + +Файл `plans/ENGINEERING_REQUIREMENTS_SKPKG.md` в onboarding/CTO workspace не входит в этот репозиторий. Стек менеджера — **Go**, контракт с реестром — **HTTP + JSON** по канону выше; расхождения с инженерным чеклистом фиксируются в рабочих тикетах Paperclip (см. раздел в `registry-api.md`). diff --git a/download.go b/download.go index 32fe6fe..20128d9 100644 --- a/download.go +++ b/download.go @@ -81,6 +81,9 @@ func DownloadSkillArchive(ctx context.Context, spec string, opts DownloadSkillOp if err != nil { return nil, err } + if t := RegistryReadBearer(); t != "" { + req.Header.Set("Authorization", "Bearer "+t) + } res, err := HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("archive request failed: %w%s", err, archiveRequestNetworkHint()) diff --git a/download_test.go b/download_test.go index 4e4768a..508a0e2 100644 --- a/download_test.go +++ b/download_test.go @@ -9,7 +9,9 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" + "time" ) func TestDownloadSkillArchive_checksumOK(t *testing.T) { @@ -24,8 +26,12 @@ func TestDownloadSkillArchive_checksumOK(t *testing.T) { ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v1/skills/demo": - lv := "1.0.0" - _ = json.NewEncoder(w).Encode(SkillDetail{Name: "demo", LatestVersion: &lv}) + _ = json.NewEncoder(w).Encode(SkillDetail{ + Name: "demo", + Versions: map[string]VersionPublicInfo{ + "1.0.0": {Yanked: false}, + }, + }) case "/api/v1/skills/demo/versions/1.0.0": _ = json.NewEncoder(w).Encode(VersionDetail{ Name: "demo", @@ -62,8 +68,12 @@ func TestDownloadSkillArchive_checksumMismatch(t *testing.T) { ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v1/skills/demo": - lv := "1.0.0" - _ = json.NewEncoder(w).Encode(SkillDetail{Name: "demo", LatestVersion: &lv}) + _ = json.NewEncoder(w).Encode(SkillDetail{ + Name: "demo", + Versions: map[string]VersionPublicInfo{ + "1.0.0": {}, + }, + }) case "/api/v1/skills/demo/versions/1.0.0": _ = json.NewEncoder(w).Encode(VersionDetail{ Name: "demo", @@ -92,3 +102,85 @@ func TestDownloadSkillArchive_checksumMismatch(t *testing.T) { t.Fatal("expected archive removed on checksum failure") } } + +func TestDownloadSkillArchive_sendsReadBearerToRegistry(t *testing.T) { + prev := HTTPClient + t.Cleanup(func() { HTTPClient = prev }) + + var sawAuth string + var ts *httptest.Server + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/skills/demo": + sawAuth = r.Header.Get("Authorization") + _ = json.NewEncoder(w).Encode(SkillDetail{ + Name: "demo", + Versions: map[string]VersionPublicInfo{"1.0.0": {}}, + }) + case "/api/v1/skills/demo/versions/1.0.0": + sawAuth = r.Header.Get("Authorization") + _ = json.NewEncoder(w).Encode(VersionDetail{ + Name: "demo", + Version: "1.0.0", + ArchiveURL: ts.URL + "/blob.tgz", + }) + case "/blob.tgz": + if r.Header.Get("Authorization") != "Bearer readtok" { + http.Error(w, "no auth", http.StatusUnauthorized) + return + } + _, _ = w.Write([]byte("x")) + default: + http.NotFound(w, r) + } + })) + defer ts.Close() + + t.Setenv("SKILLGET_REGISTRY_URL", ts.URL+"/api/v1") + t.Setenv("SKILLGET_REGISTRY_READ_TOKEN", "readtok") + HTTPClient = ts.Client() + + dir := t.TempDir() + _, err := DownloadSkillArchive(context.Background(), "demo", DownloadSkillOptions{Cwd: dir}) + if err != nil { + t.Fatal(err) + } + if sawAuth != "Bearer readtok" { + t.Fatalf("expected Bearer on registry requests, got %q", sawAuth) + } +} + +func TestSkillDetail_decodesRegistryShape(t *testing.T) { + raw := `{ + "name": "x", + "description": "d", + "author": "a", + "created_at": "2026-03-27T00:00:00Z", + "versions": { + "1.0.0": { + "manifest": {"name":"x","version":"1.0.0"}, + "checksum": "sha256:abababababababababababababababababababababababababababababababab", + "archive_url": "https://registry.example/downloads/ab.tar.gz", + "published_at": "2026-03-27T00:00:00Z", + "yanked": false + } + } + }` + var d SkillDetail + if err := json.Unmarshal([]byte(raw), &d); err != nil { + t.Fatal(err) + } + if d.Name != "x" || d.Description != "d" || d.Author != "a" { + t.Fatalf("top-level: %+v", d) + } + if !d.CreatedAt.Equal(time.Date(2026, 3, 27, 0, 0, 0, 0, time.UTC)) { + t.Fatalf("created_at: %v", d.CreatedAt) + } + v, ok := d.Versions["1.0.0"] + if !ok { + t.Fatal("missing version") + } + if v.Yanked || !strings.HasPrefix(string(v.Manifest), "{") { + t.Fatalf("version info: %+v", v) + } +} diff --git a/go.mod b/go.mod index 34ecbd5..20eeb18 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,7 @@ module github.com/getskillpack/skillget-manager -go 1.22 +go 1.22.0 + +toolchain go1.22.12 + +require golang.org/x/mod v0.23.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..370e01b --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM= +golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= diff --git a/registry_errors.go b/registry_errors.go index 7b3c9a6..4d78ad1 100644 --- a/registry_errors.go +++ b/registry_errors.go @@ -9,7 +9,7 @@ func registryHintForStatus(status int) string { case 400: return "\nHint: request may be invalid — check query params and paths against the registry API." case 401: - return "\nHint: set SKILLGET_REGISTRY_TOKEN (or SKILLGET_TOKEN) for authenticated requests." + return "\nHint: set SKILLGET_REGISTRY_READ_TOKEN for GET /api/v1 (read token), or SKILLGET_REGISTRY_TOKEN / SKILLGET_TOKEN if the same bearer is used for reads." case 403: return "\nHint: token may lack permission, or the registry blocks this operation for anonymous clients." case 404: diff --git a/resolve.go b/resolve.go index fd108b3..9e1e071 100644 --- a/resolve.go +++ b/resolve.go @@ -4,7 +4,10 @@ import ( "context" "fmt" "net/url" + "sort" "strings" + + "golang.org/x/mod/semver" ) // NameVersion holds a parsed skill spec "name" or "name@version". @@ -48,34 +51,32 @@ func ResolveInstallTarget(ctx context.Context, spec string) (*VersionDetail, err return &vd, nil } -// pickInstallableVersion chooses a version for an unpinned install. -// Prefers registry latest_version when present and not marked yanked in versions[]; -// otherwise the first non-yanked entry in versions (registry order). +// pickInstallableVersion chooses the highest semver among non-yanked versions in the +// skill detail map, matching reference registry logic (see getskillpack/registry filestore). func pickInstallableVersion(d SkillDetail) string { - if d.LatestVersion != nil { - candidate := strings.TrimSpace(*d.LatestVersion) - if candidate != "" && !versionMarkedYanked(d, candidate) { - return candidate - } + if len(d.Versions) == 0 { + return "" } - for _, v := range d.Versions { - if v.IsYanked { + var keys []string + for v, info := range d.Versions { + v = strings.TrimSpace(v) + if v == "" || info.Yanked { continue } - if v.Version != "" { - return v.Version - } + keys = append(keys, v) } - return "" + if len(keys) == 0 { + return "" + } + sort.Slice(keys, func(i, j int) bool { + return semver.Compare(canonicalSemver(keys[i]), canonicalSemver(keys[j])) < 0 + }) + return keys[len(keys)-1] } -// versionMarkedYanked reports whether v appears in d.Versions with is_yanked true. -// If v is not listed, returns false (trust latest_version from the registry). -func versionMarkedYanked(d SkillDetail, v string) bool { - for _, row := range d.Versions { - if row.Version == v { - return row.IsYanked - } +func canonicalSemver(v string) string { + if !strings.HasPrefix(v, "v") { + return "v" + v } - return false + return v } diff --git a/resolve_integration_test.go b/resolve_integration_test.go index 30026cc..57535f5 100644 --- a/resolve_integration_test.go +++ b/resolve_integration_test.go @@ -17,12 +17,9 @@ func TestResolveInstallTarget_latest(t *testing.T) { case "/api/v1/skills/demo": _ = json.NewEncoder(w).Encode(SkillDetail{ Name: "demo", - Versions: []struct { - Version string `json:"version"` - IsYanked bool `json:"is_yanked,omitempty"` - }{ - {Version: "2.0.0", IsYanked: true}, - {Version: "1.0.0"}, + Versions: map[string]VersionPublicInfo{ + "2.0.0": {Yanked: true}, + "1.0.0": {Yanked: false}, }, }) case "/api/v1/skills/demo/versions/1.0.0": @@ -78,22 +75,18 @@ func TestResolveInstallTarget_pinned(t *testing.T) { } } -func TestResolveInstallTarget_latestVersionField(t *testing.T) { +func TestResolveInstallTarget_highestSemverNonYanked(t *testing.T) { prev := HTTPClient t.Cleanup(func() { HTTPClient = prev }) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v1/skills/demo": - lv := "9.9.9" _ = json.NewEncoder(w).Encode(SkillDetail{ - Name: "demo", - LatestVersion: &lv, - Versions: []struct { - Version string `json:"version"` - IsYanked bool `json:"is_yanked,omitempty"` - }{ - {Version: "0.0.1"}, + Name: "demo", + Versions: map[string]VersionPublicInfo{ + "9.9.9": {}, + "0.0.1": {}, }, }) case "/api/v1/skills/demo/versions/9.9.9": @@ -116,27 +109,22 @@ func TestResolveInstallTarget_latestVersionField(t *testing.T) { t.Fatal(err) } if vd.Version != "9.9.9" { - t.Fatalf("expected latest_version 9.9.9, got %+v", vd) + t.Fatalf("expected highest semver 9.9.9, got %+v", vd) } } -func TestResolveInstallTarget_latestVersionYankedUsesNext(t *testing.T) { +func TestResolveInstallTarget_highestYankedFallsBack(t *testing.T) { prev := HTTPClient t.Cleanup(func() { HTTPClient = prev }) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v1/skills/demo": - lv := "2.0.0" _ = json.NewEncoder(w).Encode(SkillDetail{ - Name: "demo", - LatestVersion: &lv, - Versions: []struct { - Version string `json:"version"` - IsYanked bool `json:"is_yanked,omitempty"` - }{ - {Version: "2.0.0", IsYanked: true}, - {Version: "1.0.0"}, + Name: "demo", + Versions: map[string]VersionPublicInfo{ + "2.0.0": {Yanked: true}, + "1.0.0": {Yanked: false}, }, }) case "/api/v1/skills/demo/versions/1.0.0": @@ -159,6 +147,6 @@ func TestResolveInstallTarget_latestVersionYankedUsesNext(t *testing.T) { t.Fatal(err) } if vd.Version != "1.0.0" { - t.Fatalf("expected fallback to 1.0.0 when latest_version is yanked, got %+v", vd) + t.Fatalf("expected fallback to 1.0.0 when 2.0.0 is yanked, got %+v", vd) } } diff --git a/types.go b/types.go index 67e47ce..aab1034 100644 --- a/types.go +++ b/types.go @@ -1,5 +1,10 @@ package skillgetmanager +import ( + "encoding/json" + "time" +) + // SkillsLockfile is the on-disk lockfile shape (v1). type SkillsLockfile struct { LockfileVersion int `json:"lockfileVersion"` @@ -9,36 +14,41 @@ type SkillsLockfile struct { // ListSkillsResponse is the registry list/search JSON envelope. type ListSkillsResponse struct { Data []struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - Author string `json:"author,omitempty"` - LatestVersion string `json:"latest_version,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Author string `json:"author,omitempty"` + LatestVersion string `json:"latest_version,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` } `json:"data"` Meta *struct { - Total int `json:"total,omitempty"` + Total int `json:"total,omitempty"` + Limit int `json:"limit,omitempty"` + Offset int `json:"offset,omitempty"` } `json:"meta,omitempty"` } -// VersionDetail is returned for a concrete skill version (install target). +// VersionPublicInfo matches GET /skills/{name} → versions[version] in registry-api.md. +type VersionPublicInfo struct { + Manifest json.RawMessage `json:"manifest,omitempty"` + Checksum string `json:"checksum,omitempty"` + ArchiveURL string `json:"archive_url,omitempty"` + PublishedAt time.Time `json:"published_at,omitempty"` + Yanked bool `json:"yanked,omitempty"` +} + +// SkillDetail is returned by GET /skills/{name} (registry compiled-core contract). +type SkillDetail struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Author string `json:"author,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + Versions map[string]VersionPublicInfo `json:"versions,omitempty"` +} + +// VersionDetail is returned for GET /skills/{name}/versions/{version} (install target). type VersionDetail struct { Name string `json:"name"` Version string `json:"version"` ArchiveURL string `json:"archive_url"` Checksum string `json:"checksum,omitempty"` } - -// SkillDetail is skill metadata from the registry. -type SkillDetail struct { - Name string `json:"name"` - RepositoryURL *string `json:"repository_url"` - Homepage *string `json:"homepage"` - Dependencies []struct { - Name string `json:"name"` - Range string `json:"range,omitempty"` - } `json:"dependencies,omitempty"` - LatestVersion *string `json:"latest_version"` - Versions []struct { - Version string `json:"version"` - IsYanked bool `json:"is_yanked,omitempty"` - } `json:"versions,omitempty"` -}