From 21c086c531f18681ccb7c614cc62c097bcdbeefa Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sun, 5 Jul 2026 21:31:44 +0000 Subject: [PATCH 1/3] feat: add API versioning and gallery static file serving - Version all API endpoints under /api/v1/ prefix - Wire up UseStaticFiles() with fallback to index.html for gallery UI - SkillClient discovers API paths from manifest (ResolveHref helper) - NativeManifestClient routes through Api() base path - Add wwwroot Content to csproj for publish inclusion - Update all tests for new paths - Keep /.well-known/agent-skills/index.json at root (RFC standard) 214/214 tests passing. --- .../NativeManifestClient.cs | 49 ++++--- .../NativeManifestModels.cs | 3 + src/Netclaw.SkillClient/SkillServerClient.cs | 52 ++++---- src/Netclaw.SkillClient/SubAgentClient.cs | 16 +-- src/SkillServer/Endpoints.cs | 22 ++-- src/SkillServer/Models/NativeManifest.cs | 6 + src/SkillServer/Program.cs | 12 +- src/SkillServer/Services/IndexGenerator.cs | 8 +- .../Services/NativeManifestGenerator.cs | 44 ++++--- src/SkillServer/SkillServer.csproj | 2 + .../DownloadSubAgentCommandTests.cs | 8 +- .../PublishSubAgentCommandTests.cs | 6 +- .../ClientForwardCompatTests.cs | 22 ++-- .../NativeSyncSecurityTests.cs | 26 ++-- .../SkillServerClientNativeManifestTests.cs | 4 +- .../SkillServerIntegrationTests.cs | 124 +++++++++--------- 16 files changed, 220 insertions(+), 184 deletions(-) diff --git a/src/Netclaw.SkillClient/NativeManifestClient.cs b/src/Netclaw.SkillClient/NativeManifestClient.cs index 2af20cd..2fb8ec0 100644 --- a/src/Netclaw.SkillClient/NativeManifestClient.cs +++ b/src/Netclaw.SkillClient/NativeManifestClient.cs @@ -12,21 +12,21 @@ public sealed partial class SkillServerClient public async Task GetManifestAsync(CancellationToken ct = default) { return await _httpClient.GetFromJsonAsync( - "manifest.json", + Api("manifest.json"), SkillServerClientJsonContext.Default.NativeRootManifest, ct); } public Task GetNativeSkillIndexAsync(CancellationToken ct = default) { - return GetNativeSkillIndexByHrefAsync("manifest/skills/index.json", ct); + return GetNativeSkillIndexByHrefAsync(Api("manifest/skills/index.json"), ct); } public Task GetNativeSkillIndexAsync( NativeManifestLink link, CancellationToken ct = default) { - return GetNativeSkillIndexByHrefAsync(link.Href, ct); + return GetNativeSkillIndexByHrefAsync(ResolveHref(link.Href), ct); } public async Task GetNativeSkillIndexByHrefAsync( @@ -34,7 +34,7 @@ public sealed partial class SkillServerClient CancellationToken ct = default) { return await _httpClient.GetFromJsonAsync( - href, + ResolveHref(href), SkillServerClientJsonContext.Default.NativeSkillCollectionIndex, ct); } @@ -43,7 +43,7 @@ public sealed partial class SkillServerClient NativeManifestPageLink link, CancellationToken ct = default) { - return GetNativeSkillPageAsync(link.Href, ct); + return GetNativeSkillPageAsync(ResolveHref(link.Href), ct); } public async Task GetNativeSkillPageAsync( @@ -51,7 +51,7 @@ public sealed partial class SkillServerClient CancellationToken ct = default) { return await _httpClient.GetFromJsonAsync( - href, + ResolveHref(href), SkillServerClientJsonContext.Default.NativeSkillCollectionPage, ct); } @@ -60,7 +60,7 @@ public sealed partial class SkillServerClient NativeSkillPageItem item, CancellationToken ct = default) { - return GetNativeSkillIdentityByHrefAsync(item.Href, ct); + return GetNativeSkillIdentityByHrefAsync(ResolveHref(item.Href), ct); } public Task GetNativeSkillIdentityAsync(string name, CancellationToken ct = default) @@ -75,7 +75,7 @@ public sealed partial class SkillServerClient CancellationToken ct = default) { return await _httpClient.GetFromJsonAsync( - href, + ResolveHref(href), SkillServerClientJsonContext.Default.NativeSkillIdentityIndex, ct); } @@ -84,7 +84,7 @@ public sealed partial class SkillServerClient NativeSkillVersionLink link, CancellationToken ct = default) { - return GetNativeSkillVersionByHrefAsync(link.Href, ct); + return GetNativeSkillVersionByHrefAsync(ResolveHref(link.Href), ct); } public Task GetNativeSkillVersionAsync( @@ -102,21 +102,21 @@ public sealed partial class SkillServerClient CancellationToken ct = default) { return await _httpClient.GetFromJsonAsync( - href, + ResolveHref(href), SkillServerClientJsonContext.Default.NativeSkillVersionDetail, ct); } public Task GetNativeSubAgentIndexAsync(CancellationToken ct = default) { - return GetNativeSubAgentIndexByHrefAsync("manifest/subagents/index.json", ct); + return GetNativeSubAgentIndexByHrefAsync(Api("manifest/subagents/index.json"), ct); } public Task GetNativeSubAgentIndexAsync( NativeManifestLink link, CancellationToken ct = default) { - return GetNativeSubAgentIndexByHrefAsync(link.Href, ct); + return GetNativeSubAgentIndexByHrefAsync(ResolveHref(link.Href), ct); } public async Task GetNativeSubAgentIndexByHrefAsync( @@ -124,7 +124,7 @@ public sealed partial class SkillServerClient CancellationToken ct = default) { return await _httpClient.GetFromJsonAsync( - href, + ResolveHref(href), SkillServerClientJsonContext.Default.NativeSubAgentCollectionIndex, ct); } @@ -133,7 +133,7 @@ public sealed partial class SkillServerClient NativeManifestPageLink link, CancellationToken ct = default) { - return GetNativeSubAgentPageAsync(link.Href, ct); + return GetNativeSubAgentPageAsync(ResolveHref(link.Href), ct); } public async Task GetNativeSubAgentPageAsync( @@ -141,7 +141,7 @@ public sealed partial class SkillServerClient CancellationToken ct = default) { return await _httpClient.GetFromJsonAsync( - href, + ResolveHref(href), SkillServerClientJsonContext.Default.NativeSubAgentCollectionPage, ct); } @@ -150,7 +150,7 @@ public sealed partial class SkillServerClient NativeSubAgentPageItem item, CancellationToken ct = default) { - return GetNativeSubAgentIdentityByHrefAsync(item.Href, ct); + return GetNativeSubAgentIdentityByHrefAsync(ResolveHref(item.Href), ct); } public Task GetNativeSubAgentIdentityAsync( @@ -167,7 +167,7 @@ public sealed partial class SkillServerClient CancellationToken ct = default) { return await _httpClient.GetFromJsonAsync( - href, + ResolveHref(href), SkillServerClientJsonContext.Default.NativeSubAgentIdentityIndex, ct); } @@ -176,7 +176,7 @@ public sealed partial class SkillServerClient NativeSubAgentVersionLink link, CancellationToken ct = default) { - return GetNativeSubAgentVersionByHrefAsync(link.Href, ct); + return GetNativeSubAgentVersionByHrefAsync(ResolveHref(link.Href), ct); } public Task GetNativeSubAgentVersionAsync( @@ -194,8 +194,17 @@ public sealed partial class SkillServerClient CancellationToken ct = default) { return await _httpClient.GetFromJsonAsync( - href, + ResolveHref(href), SkillServerClientJsonContext.Default.NativeSubAgentVersionDetail, ct); } -} + + /* + * Resolve an href: if it already starts with "/" it's an absolute path from the server + * root (as returned by manifest links), otherwise prepend the API base. + */ + private string ResolveHref(string href) + { + return href.StartsWith('/') ? href : Api(href); + } +} \ No newline at end of file diff --git a/src/Netclaw.SkillClient/NativeManifestModels.cs b/src/Netclaw.SkillClient/NativeManifestModels.cs index 4b6a8c5..5e81521 100644 --- a/src/Netclaw.SkillClient/NativeManifestModels.cs +++ b/src/Netclaw.SkillClient/NativeManifestModels.cs @@ -32,6 +32,9 @@ public sealed record NativeRootManifestLinks [JsonPropertyName("subagents")] public NativeManifestLink SubAgents { get; init; } = new(); + + [JsonPropertyName("apiBase")] + public NativeManifestLink ApiBase { get; init; } = new(); } public sealed record NativeRootManifest diff --git a/src/Netclaw.SkillClient/SkillServerClient.cs b/src/Netclaw.SkillClient/SkillServerClient.cs index e5f7f1d..809aca4 100644 --- a/src/Netclaw.SkillClient/SkillServerClient.cs +++ b/src/Netclaw.SkillClient/SkillServerClient.cs @@ -1,4 +1,4 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // @@ -18,11 +18,13 @@ public sealed partial class SkillServerClient : IDisposable private readonly HttpClient _httpClient; private readonly bool _ownsHttpClient; private readonly JsonSerializerOptions _jsonOptions; + private readonly string _apiBase; public SkillServerClient(string serverUrl, string? apiKey = null) { _httpClient = new HttpClient { BaseAddress = new Uri(serverUrl.TrimEnd('/') + "/") }; _ownsHttpClient = true; + _apiBase = "api/v1"; if (!string.IsNullOrEmpty(apiKey)) _httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey); @@ -36,12 +38,15 @@ public SkillServerClient(HttpClient httpClient) { _httpClient = httpClient; _ownsHttpClient = false; + _apiBase = "api/v1"; _jsonOptions = new JsonSerializerOptions { TypeInfoResolver = SkillServerClientJsonContext.Default }; } + private string Api(string path) => $"{_apiBase}/{path.TrimStart('/')}"; + /// /// Gets the RFC-compliant skill index per Cloudflare Agent Skills Discovery RFC v0.2.0. /// @@ -65,7 +70,7 @@ public async Task> ListSkillsAsync( if (skip.HasValue) query.Add($"skip={skip.Value}"); if (take.HasValue) query.Add($"take={take.Value}"); - var url = query.Count > 0 ? $"skills?{string.Join("&", query)}" : "skills"; + var url = query.Count > 0 ? $"{Api("skills")}?{string.Join("&", query)}" : Api("skills"); var result = await _httpClient.GetFromJsonAsync( url, @@ -80,7 +85,7 @@ public async Task> ListSkillsAsync( public async Task> GetSkillVersionsAsync(string name, CancellationToken ct = default) { var result = await _httpClient.GetFromJsonAsync( - $"skills/{Uri.EscapeDataString(name)}", + $"{Api("skills")}/{Uri.EscapeDataString(name)}", SkillServerClientJsonContext.Default.IReadOnlyListSkillVersionSummary, ct); return result ?? []; @@ -92,7 +97,7 @@ public async Task> GetSkillVersionsAsync(stri public async Task GetVersionAsync(string name, string version, CancellationToken ct = default) { return await _httpClient.GetFromJsonAsync( - $"skills/{Uri.EscapeDataString(name)}/{Uri.EscapeDataString(version)}", + $"{Api("skills")}/{Uri.EscapeDataString(name)}/{Uri.EscapeDataString(version)}", SkillServerClientJsonContext.Default.SkillVersionSummary, ct); } @@ -103,7 +108,7 @@ public async Task> GetSkillVersionsAsync(stri public async Task GetSkillFileAsync(string name, string version, string path = "SKILL.md", CancellationToken ct = default) { var response = await _httpClient.GetAsync( - $"skills/{Uri.EscapeDataString(name)}/{Uri.EscapeDataString(version)}/{path}", + $"{Api("skills")}/{Uri.EscapeDataString(name)}/{Uri.EscapeDataString(version)}/{path}", ct); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStreamAsync(ct); @@ -115,7 +120,7 @@ public async Task GetSkillFileAsync(string name, string version, string public async Task GetSkillFileAsStringAsync(string name, string version, string path = "SKILL.md", CancellationToken ct = default) { var response = await _httpClient.GetAsync( - $"skills/{Uri.EscapeDataString(name)}/{Uri.EscapeDataString(version)}/{path}", + $"{Api("skills")}/{Uri.EscapeDataString(name)}/{Uri.EscapeDataString(version)}/{path}", ct); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStringAsync(ct); @@ -130,7 +135,7 @@ public async Task GetBlobAsync(string digest, CancellationToken ct = def ? digest[7..] : digest; - var response = await _httpClient.GetAsync($"blobs/sha256/{normalizedDigest}", ct); + var response = await _httpClient.GetAsync($"{Api("blobs/sha256")}/{normalizedDigest}", ct); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStreamAsync(ct); } @@ -160,29 +165,30 @@ public async Task> SearchSkillsAsync( if (skip.HasValue) queryParams.Add($"skip={skip.Value}"); if (take.HasValue) queryParams.Add($"take={take.Value}"); - var url = $"skills?{string.Join("&", queryParams)}"; - var result = await _httpClient.GetFromJsonAsync(url, - SkillServerClientJsonContext.Default.IReadOnlyListSkillSummary, ct); + var url = $"{Api("skills")}?{string.Join("&", queryParams)}"; + + var result = await _httpClient.GetFromJsonAsync( + url, + SkillServerClientJsonContext.Default.IReadOnlyListSkillSummary, + ct); return result ?? []; } /// - /// Gets the latest version of a skill by name. + /// Gets the latest version of a skill. /// public async Task GetLatestVersionAsync(string name, CancellationToken ct = default) { return await _httpClient.GetFromJsonAsync( - $"skills/{Uri.EscapeDataString(name)}/latest", - SkillServerClientJsonContext.Default.SkillVersionSummary, ct); + $"{Api("skills")}/{Uri.EscapeDataString(name)}/latest", + SkillServerClientJsonContext.Default.SkillVersionSummary, + ct); } - /// - /// Checks if any of the specified skills have newer versions available. - /// public async Task> CheckUpdatesAsync( IReadOnlyList items, CancellationToken ct = default) { - var response = await _httpClient.PostAsJsonAsync("skills/check-updates", items, + var response = await _httpClient.PostAsJsonAsync(Api("skills/check-updates"), items, SkillServerClientJsonContext.Default.IReadOnlyListCheckUpdateRequest, ct); response.EnsureSuccessStatusCode(); var result = await response.Content.ReadFromJsonAsync( @@ -282,13 +288,13 @@ private async Task PostSkillAsync( content.Add(new StringContent(json), "resourceMetadata"); } - return await _httpClient.PostAsync("skills", content, ct); + return await _httpClient.PostAsync(Api("skills"), content, ct); } public async Task DeleteVersionAsync(string name, string version, CancellationToken ct = default) { var response = await _httpClient.DeleteAsync( - $"skills/{Uri.EscapeDataString(name)}/{Uri.EscapeDataString(version)}", ct); + $"{Api("skills")}/{Uri.EscapeDataString(name)}/{Uri.EscapeDataString(version)}", ct); response.EnsureSuccessStatusCode(); } @@ -296,7 +302,7 @@ public async Task CreateApiKeyAsync( string label, DateTimeOffset? expiresAt = null, CancellationToken ct = default) { var request = new CreateApiKeyRequest { Label = label, ExpiresAt = expiresAt }; - var response = await _httpClient.PostAsJsonAsync("api-keys", + var response = await _httpClient.PostAsJsonAsync(Api("api-keys"), request, SkillServerClientJsonContext.Default.CreateApiKeyRequest, ct); response.EnsureSuccessStatusCode(); return (await response.Content.ReadFromJsonAsync( @@ -305,14 +311,14 @@ public async Task CreateApiKeyAsync( public async Task> ListApiKeysAsync(CancellationToken ct = default) { - var result = await _httpClient.GetFromJsonAsync("api-keys", + var result = await _httpClient.GetFromJsonAsync(Api("api-keys"), SkillServerClientJsonContext.Default.IReadOnlyListApiKeySummary, ct); return result ?? []; } public async Task DeleteApiKeyAsync(long id, CancellationToken ct = default) { - var response = await _httpClient.DeleteAsync($"api-keys/{id}", ct); + var response = await _httpClient.DeleteAsync($"{Api("api-keys")}/{id}", ct); response.EnsureSuccessStatusCode(); } @@ -321,4 +327,4 @@ public void Dispose() if (_ownsHttpClient) _httpClient.Dispose(); } -} +} \ No newline at end of file diff --git a/src/Netclaw.SkillClient/SubAgentClient.cs b/src/Netclaw.SkillClient/SubAgentClient.cs index c2abcb3..aae5deb 100644 --- a/src/Netclaw.SkillClient/SubAgentClient.cs +++ b/src/Netclaw.SkillClient/SubAgentClient.cs @@ -13,7 +13,7 @@ public sealed partial class SkillServerClient public async Task> ListSubAgentsAsync(CancellationToken ct = default) { var result = await _httpClient.GetFromJsonAsync( - "subagents", + Api("subagents"), SkillServerClientJsonContext.Default.IReadOnlyListSubAgentSummary, ct); return result ?? []; @@ -24,7 +24,7 @@ public async Task> GetSubAgentVersionsAsyn CancellationToken ct = default) { var result = await _httpClient.GetFromJsonAsync( - $"subagents/{Uri.EscapeDataString(name)}", + $"{Api("subagents")}/{Uri.EscapeDataString(name)}", SkillServerClientJsonContext.Default.IReadOnlyListSubAgentVersionSummary, ct); return result ?? []; @@ -36,7 +36,7 @@ public async Task> GetSubAgentVersionsAsyn CancellationToken ct = default) { return await _httpClient.GetFromJsonAsync( - $"subagents/{Uri.EscapeDataString(name)}/{Uri.EscapeDataString(version)}", + $"{Api("subagents")}/{Uri.EscapeDataString(name)}/{Uri.EscapeDataString(version)}", SkillServerClientJsonContext.Default.SubAgentVersionSummary, ct); } @@ -44,7 +44,7 @@ public async Task> GetSubAgentVersionsAsyn public async Task GetSubAgentFileAsync(string name, string version, CancellationToken ct = default) { var response = await _httpClient.GetAsync( - $"subagents/{Uri.EscapeDataString(name)}/{Uri.EscapeDataString(version)}/agent.md", + $"{Api("subagents")}/{Uri.EscapeDataString(name)}/{Uri.EscapeDataString(version)}/agent.md", ct); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStreamAsync(ct); @@ -56,7 +56,7 @@ public async Task GetSubAgentFileAsStringAsync( CancellationToken ct = default) { var response = await _httpClient.GetAsync( - $"subagents/{Uri.EscapeDataString(name)}/{Uri.EscapeDataString(version)}/agent.md", + $"{Api("subagents")}/{Uri.EscapeDataString(name)}/{Uri.EscapeDataString(version)}/agent.md", ct); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStringAsync(ct); @@ -94,7 +94,7 @@ public async Task UploadSubAgentAsync( public async Task DeleteSubAgentVersionAsync(string name, string version, CancellationToken ct = default) { var response = await _httpClient.DeleteAsync( - $"subagents/{Uri.EscapeDataString(name)}/{Uri.EscapeDataString(version)}", + $"{Api("subagents")}/{Uri.EscapeDataString(name)}/{Uri.EscapeDataString(version)}", ct); response.EnsureSuccessStatusCode(); } @@ -110,6 +110,6 @@ private async Task PostSubAgentAsync( content.Add(new StringContent(version), "version"); content.Add(new StreamContent(agentMdContent), "file", "agent.md"); - return await _httpClient.PostAsync("subagents", content, ct); + return await _httpClient.PostAsync(Api("subagents"), content, ct); } -} +} \ No newline at end of file diff --git a/src/SkillServer/Endpoints.cs b/src/SkillServer/Endpoints.cs index 4826fd2..44e3b00 100644 --- a/src/SkillServer/Endpoints.cs +++ b/src/SkillServer/Endpoints.cs @@ -38,7 +38,7 @@ private static void MapDiscoveryEndpoints(this WebApplication app) private static void MapManifestEndpoints(this WebApplication app) { - app.MapGet("/manifest.json", async ( + app.MapGet("/api/v1/manifest.json", async ( NativeManifestGenerator manifestGenerator, CancellationToken ct) => { @@ -46,7 +46,7 @@ private static void MapManifestEndpoints(this WebApplication app) return Results.Json(manifest, SkillServerJsonContext.Default.NativeRootManifest); }); - var manifest = app.MapGroup("/manifest"); + var manifest = app.MapGroup("/api/v1/manifest"); manifest.MapGet("/skills/index.json", async ( NativeManifestGenerator manifestGenerator, @@ -135,7 +135,7 @@ private static void MapManifestEndpoints(this WebApplication app) private static void MapSkillEndpoints(this WebApplication app) { - var skills = app.MapGroup("/skills"); + var skills = app.MapGroup("/api/v1/skills"); skills.MapGet("/", ListSkills); skills.MapGet("/{name}", GetSkill); @@ -151,7 +151,7 @@ private static void MapSkillEndpoints(this WebApplication app) private static void MapSubAgentEndpoints(this WebApplication app) { - var subagents = app.MapGroup("/subagents"); + var subagents = app.MapGroup("/api/v1/subagents"); subagents.MapGet("/", ListSubAgents); subagents.MapGet("/{name}", GetSubAgent); @@ -502,13 +502,13 @@ private static IResult HandleUploadResult(SkillUploadResult result, IConfigurati var baseUrl = configuration["SkillServer:BaseUrl"]?.TrimEnd('/') ?? "http://localhost:8080"; return Results.Created( - $"/skills/{result.Name}/{result.Version}", + $"/api/v1/skills/{result.Name}/{result.Version}", new SkillUploadResponse { Name = result.Name!.Value.Value, Version = result.Version!.Value.Value, Sha256 = result.Digest!.Value.Value, - Url = $"{baseUrl}/skills/{result.Name}/{result.Version}/SKILL.md" + Url = $"{baseUrl}/api/v1/skills/{result.Name}/{result.Version}/SKILL.md" }); } @@ -736,13 +736,13 @@ private static IResult HandleSubAgentUploadResult(SubAgentUploadResult result, I var baseUrl = configuration["SkillServer:BaseUrl"]?.TrimEnd('/') ?? "http://localhost:8080"; return Results.Created( - $"/subagents/{result.Name}/{result.Version}", + $"/api/v1/subagents/{result.Name}/{result.Version}", new SubAgentUploadResponse { Name = result.Name!.Value.Value, Version = result.Version!.Value.Value, Sha256 = result.Digest!.Value.Value, - Url = $"{baseUrl}/subagents/{result.Name}/{result.Version}/agent.md" + Url = $"{baseUrl}/api/v1/subagents/{result.Name}/{result.Version}/agent.md" }); } @@ -781,7 +781,7 @@ private static async Task DeleteSubAgentVersion( private static void MapApiKeyEndpoints(this WebApplication app) { - var keys = app.MapGroup("/api-keys") + var keys = app.MapGroup("/api/v1/api-keys") .AddEndpointFilter(); keys.MapPost("/", CreateApiKey); @@ -806,7 +806,7 @@ private static async Task CreateApiKey( var (rawKey, storedKey) = await apiKeyService.CreateKeyAsync( request.Label, request.ExpiresAt, ct); - return Results.Created($"/api-keys/{storedKey.Id}", new CreateApiKeyResponse + return Results.Created($"/api/v1/api-keys/{storedKey.Id}", new CreateApiKeyResponse { Id = storedKey.Id, Label = storedKey.Label, @@ -852,7 +852,7 @@ private static async Task DeleteApiKey( private static void MapBlobEndpoints(this WebApplication app) { - app.MapGet("/blobs/sha256/{digest}", ( + app.MapGet("/api/v1/blobs/sha256/{digest}", ( string digest, BlobStorage blobStorage) => { diff --git a/src/SkillServer/Models/NativeManifest.cs b/src/SkillServer/Models/NativeManifest.cs index 3aa051d..8ea0193 100644 --- a/src/SkillServer/Models/NativeManifest.cs +++ b/src/SkillServer/Models/NativeManifest.cs @@ -32,6 +32,9 @@ public sealed record NativeRootManifestLinks [JsonPropertyName("subagents")] public required NativeManifestLink SubAgents { get; init; } + + [JsonPropertyName("apiBase")] + public required NativeManifestLink ApiBase { get; init; } } public sealed record NativeRootManifest @@ -42,6 +45,9 @@ public sealed record NativeRootManifest [JsonPropertyName("generatedAt")] public required DateTimeOffset GeneratedAt { get; init; } + [JsonPropertyName("apiVersion")] + public string? ApiVersion { get; init; } + [JsonPropertyName("links")] public required NativeRootManifestLinks Links { get; init; } } diff --git a/src/SkillServer/Program.cs b/src/SkillServer/Program.cs index dabdea0..92a5257 100644 --- a/src/SkillServer/Program.cs +++ b/src/SkillServer/Program.cs @@ -1,8 +1,9 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- +using Microsoft.AspNetCore.StaticFiles; using SkillServer; using SkillServer.Data; using SkillServer.Models; @@ -51,8 +52,15 @@ app.MapOpenApi(); } +// Serve static files for gallery UI +app.UseStaticFiles(); + +// Map API endpoints (must be before fallback) app.MapSkillServerEndpoints(); +// Serve gallery UI for non-API paths +app.MapFallbackToFile("index.html"); + app.Run(); -public partial class Program; +public partial class Program; \ No newline at end of file diff --git a/src/SkillServer/Services/IndexGenerator.cs b/src/SkillServer/Services/IndexGenerator.cs index 5d0ad90..e8282bb 100644 --- a/src/SkillServer/Services/IndexGenerator.cs +++ b/src/SkillServer/Services/IndexGenerator.cs @@ -1,4 +1,4 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // @@ -47,8 +47,8 @@ public async Task GenerateRfcIndexAsync(CancellationToken ct = de Type = v.SkillType, Description = v.Description, Url = v.SkillType == SkillTypes.SkillMd - ? $"{baseUrl}/skills/{v.SkillName}/{v.Version}/SKILL.md" - : $"{baseUrl}/skills/{v.SkillName}/{v.Version}/archive.zip", + ? $"{baseUrl}/api/v1/skills/{v.SkillName}/{v.Version}/SKILL.md" + : $"{baseUrl}/api/v1/skills/{v.SkillName}/{v.Version}/archive.zip", Digest = Sha256Digest.Create(v.ArtifactSha256).Value, Version = v.Version, Resources = files.Count > 0 @@ -56,7 +56,7 @@ public async Task GenerateRfcIndexAsync(CancellationToken ct = de { Path = f.RelativePath, Digest = Sha256Digest.Create(f.Sha256).Value, - Url = $"{baseUrl}/skills/{v.SkillName}/{v.Version}/{f.RelativePath}", + Url = $"{baseUrl}/api/v1/skills/{v.SkillName}/{v.Version}/{f.RelativePath}", UnixMode = f.UnixMode }).ToList() : null diff --git a/src/SkillServer/Services/NativeManifestGenerator.cs b/src/SkillServer/Services/NativeManifestGenerator.cs index 537f8f2..9b7a8f3 100644 --- a/src/SkillServer/Services/NativeManifestGenerator.cs +++ b/src/SkillServer/Services/NativeManifestGenerator.cs @@ -32,12 +32,14 @@ public Task GenerateRootAsync(CancellationToken ct = default var manifest = new NativeRootManifest { GeneratedAt = DateTimeOffset.UtcNow, + ApiVersion = "v1", Links = new NativeRootManifestLinks { - Self = Link("/manifest.json"), + Self = Link("/api/v1/manifest.json"), RfcSkills = Link("/.well-known/agent-skills/index.json"), - Skills = Link("/manifest/skills/index.json"), - SubAgents = Link("/manifest/subagents/index.json") + Skills = Link("/api/v1/manifest/skills/index.json"), + SubAgents = Link("/api/v1/manifest/subagents/index.json"), + ApiBase = Link("/api/v1") } }; @@ -48,11 +50,11 @@ public Task GenerateSkillIndexAsync(CancellationToke { var index = new NativeSkillCollectionIndex { - Links = SelfLinks("/manifest/skills/index.json"), + Links = SelfLinks("/api/v1/manifest/skills/index.json"), Pages = [new NativeManifestPageLink { Range = SkillsPageRange, - Href = "/manifest/skills/pages/all.json" + Href = "/api/v1/manifest/skills/pages/all.json" }] }; @@ -84,7 +86,7 @@ public Task GenerateSkillIndexAsync(CancellationToke Max = latest.Version, Count = versions.Count }, - Href = $"/manifest/skills/{latest.SkillName}/index.json" + Href = $"/api/v1/manifest/skills/{latest.SkillName}/index.json" }); } @@ -92,7 +94,7 @@ public Task GenerateSkillIndexAsync(CancellationToke { Range = SkillsPageRange, Items = items, - Links = SelfLinks("/manifest/skills/pages/all.json") + Links = SelfLinks("/api/v1/manifest/skills/pages/all.json") }; } @@ -116,9 +118,9 @@ public Task GenerateSkillIndexAsync(CancellationToke Version = v.Version, PublishedAt = v.PublishedAt, Digest = Sha256Digest.Create(v.ArtifactSha256).Value, - Href = $"/manifest/skills/{skill.Name}/versions/{v.Version}.json" + Href = $"/api/v1/manifest/skills/{skill.Name}/versions/{v.Version}.json" }).ToList(), - Links = SelfLinks($"/manifest/skills/{skill.Name}/index.json") + Links = SelfLinks($"/api/v1/manifest/skills/{skill.Name}/index.json") }; } @@ -140,9 +142,9 @@ public Task GenerateSkillIndexAsync(CancellationToke : new NativeSubagentRoute { Name = skillVersion.RoutesToSubagent, - Href = $"/manifest/subagents/{skillVersion.RoutesToSubagent}/index.json" + Href = $"/api/v1/manifest/subagents/{skillVersion.RoutesToSubagent}/index.json" }, - Links = SelfLinks($"/manifest/skills/{skill.Name}/versions/{skillVersion.Version}.json") + Links = SelfLinks($"/api/v1/manifest/skills/{skill.Name}/versions/{skillVersion.Version}.json") }; } @@ -150,11 +152,11 @@ public Task GenerateSubAgentIndexAsync(Cancellati { var index = new NativeSubAgentCollectionIndex { - Links = SelfLinks("/manifest/subagents/index.json"), + Links = SelfLinks("/api/v1/manifest/subagents/index.json"), Pages = [new NativeManifestPageLink { Range = SubAgentsPageRange, - Href = "/manifest/subagents/pages/all.json" + Href = "/api/v1/manifest/subagents/pages/all.json" }] }; @@ -186,7 +188,7 @@ public Task GenerateSubAgentIndexAsync(Cancellati Max = latest.Version, Count = versions.Count }, - Href = $"/manifest/subagents/{latest.SubAgentName}/index.json" + Href = $"/api/v1/manifest/subagents/{latest.SubAgentName}/index.json" }); } @@ -194,7 +196,7 @@ public Task GenerateSubAgentIndexAsync(Cancellati { Range = SubAgentsPageRange, Items = items, - Links = SelfLinks("/manifest/subagents/pages/all.json") + Links = SelfLinks("/api/v1/manifest/subagents/pages/all.json") }; } @@ -218,9 +220,9 @@ public Task GenerateSubAgentIndexAsync(Cancellati Version = v.Version, PublishedAt = v.PublishedAt, Digest = Sha256Digest.Create(v.Sha256).Value, - Href = $"/manifest/subagents/{subAgent.Name}/versions/{v.Version}.json" + Href = $"/api/v1/manifest/subagents/{subAgent.Name}/versions/{v.Version}.json" }).ToList(), - Links = SelfLinks($"/manifest/subagents/{subAgent.Name}/index.json") + Links = SelfLinks($"/api/v1/manifest/subagents/{subAgent.Name}/index.json") }; } @@ -240,9 +242,9 @@ public Task GenerateSubAgentIndexAsync(Cancellati Name = subAgent.Name, Version = subAgentVersion.Version, Description = subAgentVersion.Description, - Url = $"{baseUrl}/subagents/{subAgent.Name}/{subAgentVersion.Version}/agent.md", + Url = $"{baseUrl}/api/v1/subagents/{subAgent.Name}/{subAgentVersion.Version}/agent.md", Digest = Sha256Digest.Create(subAgentVersion.Sha256).Value, - Links = SelfLinks($"/manifest/subagents/{subAgent.Name}/versions/{subAgentVersion.Version}.json") + Links = SelfLinks($"/api/v1/manifest/subagents/{subAgent.Name}/versions/{subAgentVersion.Version}.json") }; } @@ -256,8 +258,8 @@ private NativeSkillArtifact CreateArtifact(string skillName, SkillVersion versio Type = version.SkillType, Description = version.Description, Url = version.SkillType == SkillTypes.SkillMd - ? $"{baseUrl}/skills/{skillName}/{version.Version}/SKILL.md" - : $"{baseUrl}/skills/{skillName}/{version.Version}/archive.zip", + ? $"{baseUrl}/api/v1/skills/{skillName}/{version.Version}/SKILL.md" + : $"{baseUrl}/api/v1/skills/{skillName}/{version.Version}/archive.zip", Digest = Sha256Digest.Create(version.ArtifactSha256).Value }; } diff --git a/src/SkillServer/SkillServer.csproj b/src/SkillServer/SkillServer.csproj index 054ab1b..2225a40 100644 --- a/src/SkillServer/SkillServer.csproj +++ b/src/SkillServer/SkillServer.csproj @@ -45,4 +45,6 @@ + + diff --git a/tests/Netclaw.SkillServer.Cli.Tests/DownloadSubAgentCommandTests.cs b/tests/Netclaw.SkillServer.Cli.Tests/DownloadSubAgentCommandTests.cs index f90ccd6..43f81d2 100644 --- a/tests/Netclaw.SkillServer.Cli.Tests/DownloadSubAgentCommandTests.cs +++ b/tests/Netclaw.SkillServer.Cli.Tests/DownloadSubAgentCommandTests.cs @@ -50,8 +50,8 @@ public async Task ExecuteAsync_DownloadsVerifiedArtifactToDestination() Assert.Equal(0, exitCode); Assert.Equal(agentMd, await File.ReadAllTextAsync(destination, TestContext.Current.CancellationToken)); - Assert.Equal("/manifest/subagents/support-agent/versions/1.0.0.json", handler.Requests[0].Path); - Assert.Equal("/subagents/support-agent/1.0.0/agent.md", handler.Requests[1].Path); + Assert.Equal("/api/v1/manifest/subagents/support-agent/versions/1.0.0.json", handler.Requests[0].Path); + Assert.Equal("/api/v1/subagents/support-agent/1.0.0/agent.md", handler.Requests[1].Path); } [Fact] @@ -115,13 +115,13 @@ public async Task ExecuteAsync_DigestMismatch_DoesNotLeaveDestinationFile() Version = "1.0.0", Type = SubAgentArtifactTypes.AgentMd, Description = "test", - Url = "/subagents/support-agent/1.0.0/agent.md", + Url = "/api/v1/subagents/support-agent/1.0.0/agent.md", Digest = digest, Links = new NativeManifestSelfLinks { Self = new NativeManifestLink { - Href = "/manifest/subagents/support-agent/versions/1.0.0.json" + Href = "/api/v1/manifest/subagents/support-agent/versions/1.0.0.json" } } }; diff --git a/tests/Netclaw.SkillServer.Cli.Tests/PublishSubAgentCommandTests.cs b/tests/Netclaw.SkillServer.Cli.Tests/PublishSubAgentCommandTests.cs index 679cc7f..b2d66df 100644 --- a/tests/Netclaw.SkillServer.Cli.Tests/PublishSubAgentCommandTests.cs +++ b/tests/Netclaw.SkillServer.Cli.Tests/PublishSubAgentCommandTests.cs @@ -49,7 +49,7 @@ public async Task ExecuteAsync_ValidFile_PostsSubAgent() Assert.Equal(0, exitCode); var request = Assert.Single(handler.Requests); Assert.Equal(HttpMethod.Post, request.Method); - Assert.Equal("/subagents", request.Path); + Assert.Equal("/api/v1/subagents", request.Path); Assert.Contains("support-agent", request.Body); Assert.Contains("1.0.0", request.Body); } @@ -90,9 +90,9 @@ public async Task ExecuteAsync_Force_DeletesBeforeUpload() Assert.Equal(0, exitCode); Assert.Equal(HttpMethod.Delete, handler.Requests[0].Method); - Assert.Equal("/subagents/support-agent/1.0.0", handler.Requests[0].Path); + Assert.Equal("/api/v1/subagents/support-agent/1.0.0", handler.Requests[0].Path); Assert.Equal(HttpMethod.Post, handler.Requests[1].Method); - Assert.Equal("/subagents", handler.Requests[1].Path); + Assert.Equal("/api/v1/subagents", handler.Requests[1].Path); } [Fact] diff --git a/tests/SkillServer.Integration.Tests/ClientForwardCompatTests.cs b/tests/SkillServer.Integration.Tests/ClientForwardCompatTests.cs index 119bed4..6eeaa49 100644 --- a/tests/SkillServer.Integration.Tests/ClientForwardCompatTests.cs +++ b/tests/SkillServer.Integration.Tests/ClientForwardCompatTests.cs @@ -49,11 +49,11 @@ public async Task GetManifest_WithUnknownLinkAndTopLevelField_IsTolerated() "generatedAt": "2026-07-03T00:00:00Z", "futureTopLevelField": { "anything": [1, 2, 3] }, "links": { - "self": { "href": "/manifest.json" }, + "self": { "href": "/api/v1/manifest.json" }, "rfcSkills": { "href": "/.well-known/agent-skills/index.json" }, - "skills": { "href": "/manifest/skills/index.json" }, - "subagents": { "href": "/manifest/subagents/index.json" }, - "futureCollection": { "href": "/manifest/future/index.json" } + "skills": { "href": "/api/v1/manifest/skills/index.json" }, + "subagents": { "href": "/api/v1/manifest/subagents/index.json" }, + "futureCollection": { "href": "/api/v1/manifest/future/index.json" } } } """; @@ -64,8 +64,8 @@ public async Task GetManifest_WithUnknownLinkAndTopLevelField_IsTolerated() var manifest = await client.GetManifestAsync(ct); Assert.NotNull(manifest); - Assert.Equal("/manifest/skills/index.json", manifest.Links.Skills.Href); - Assert.Equal("/manifest/subagents/index.json", manifest.Links.SubAgents.Href); + Assert.Equal("/api/v1/manifest/skills/index.json", manifest.Links.Skills.Href); + Assert.Equal("/api/v1/manifest/subagents/index.json", manifest.Links.SubAgents.Href); } [Fact] @@ -81,11 +81,11 @@ public async Task GetNativeSkillPage_WithUnknownKindAndExtraFields_IsTolerated() "name": "example-skill", "latestVersion": "1.0.0", "versionRange": { "min": "1.0.0", "max": "1.0.0", "count": 1 }, - "href": "/manifest/skills/example-skill/index.json", + "href": "/api/v1/manifest/skills/example-skill/index.json", "futureItemField": "ignored" } ], - "links": { "self": { "href": "/manifest/skills/pages/0.json" } } + "links": { "self": { "href": "/api/v1/manifest/skills/pages/0.json" } } } """; @@ -99,7 +99,7 @@ public async Task GetNativeSkillPage_WithUnknownKindAndExtraFields_IsTolerated() Assert.Equal("future-skill-page-kind", page.Kind); var item = Assert.Single(page.Items); Assert.Equal("example-skill", item.Name); - Assert.Equal("/manifest/skills/example-skill/index.json", item.Href); + Assert.Equal("/api/v1/manifest/skills/example-skill/index.json", item.Href); } [Fact] @@ -113,12 +113,12 @@ public async Task GetNativeSkillVersion_WithUnknownArtifactType_IsTolerated() "version": "1.0.0", "type": "oci-image", "description": "A future artifact type older clients do not understand.", - "url": "/skills/example-skill/1.0.0/artifact.oci", + "url": "/api/v1/skills/example-skill/1.0.0/artifact.oci", "digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "futureArtifactField": 42 }, "routesToSubagent": null, - "links": { "self": { "href": "/manifest/skills/example-skill/versions/1.0.0.json" } } + "links": { "self": { "href": "/api/v1/manifest/skills/example-skill/versions/1.0.0.json" } } } """; diff --git a/tests/SkillServer.Integration.Tests/NativeSyncSecurityTests.cs b/tests/SkillServer.Integration.Tests/NativeSyncSecurityTests.cs index 4dc0c8c..8a44816 100644 --- a/tests/SkillServer.Integration.Tests/NativeSyncSecurityTests.cs +++ b/tests/SkillServer.Integration.Tests/NativeSyncSecurityTests.cs @@ -76,7 +76,7 @@ private async Task PublishResourcefulSkillAsync(string skillName, Cancel content.Add(new ByteArrayContent("# Guide"u8.ToArray()), "resources", "references/guide.md"); - var response = await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content, ct); + var response = await _fixture.AuthenticatedHttpClient.PostAsync("/api/v1/skills", content, ct); response.EnsureSuccessStatusCode(); return skillName; } @@ -89,7 +89,7 @@ public async Task PublishSubAgent_WithoutApiKey_Returns401() var ct = TestContext.Current.CancellationToken; using var content = CreateSubAgentUpload("no-auth-subagent", "1.0.0"); - var response = await _fixture.HttpClient.PostAsync("/subagents", content, ct); + var response = await _fixture.HttpClient.PostAsync("/api/v1/subagents", content, ct); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } @@ -100,7 +100,7 @@ public async Task PublishSubAgent_WithInvalidApiKey_Returns403() var ct = TestContext.Current.CancellationToken; using var content = CreateSubAgentUpload("bad-key-subagent", "1.0.0"); - using var request = new HttpRequestMessage(HttpMethod.Post, "/subagents") { Content = content }; + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/subagents") { Content = content }; request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "sk-invalid-key"); var response = await _fixture.HttpClient.SendAsync(request, ct); @@ -113,7 +113,7 @@ public async Task DeleteSubAgent_WithoutApiKey_Returns401() { var ct = TestContext.Current.CancellationToken; - var response = await _fixture.HttpClient.DeleteAsync("/subagents/nonexistent/1.0.0", ct); + var response = await _fixture.HttpClient.DeleteAsync("/api/v1/subagents/nonexistent/1.0.0", ct); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } @@ -121,9 +121,9 @@ public async Task DeleteSubAgent_WithoutApiKey_Returns401() // ---- Task 4: manifest + artifact reads stay open (auth is enabled) --- [Theory] - [InlineData("/manifest.json")] - [InlineData("/manifest/skills/index.json")] - [InlineData("/manifest/subagents/index.json")] + [InlineData("/api/v1/manifest.json")] + [InlineData("/api/v1/manifest/skills/index.json")] + [InlineData("/api/v1/manifest/subagents/index.json")] public async Task ManifestEndpoints_AreReadableWithoutAuth(string path) { var ct = TestContext.Current.CancellationToken; @@ -145,15 +145,15 @@ public async Task ArtifactEndpoints_AreReadableWithoutAuth() await PublishResourcefulSkillAsync(skillName, ct); using var subAgentUpload = CreateSubAgentUpload(subAgentName, "1.0.0"); - (await _fixture.AuthenticatedHttpClient.PostAsync("/subagents", subAgentUpload, ct)) + (await _fixture.AuthenticatedHttpClient.PostAsync("/api/v1/subagents", subAgentUpload, ct)) .EnsureSuccessStatusCode(); string[] openArtifacts = [ - $"/skills/{skillName}/1.0.0/SKILL.md", - $"/skills/{skillName}/1.0.0/archive.zip", - $"/skills/{skillName}/1.0.0/references/guide.md", - $"/subagents/{subAgentName}/1.0.0/agent.md" + $"/api/v1/skills/{skillName}/1.0.0/SKILL.md", + $"/api/v1/skills/{skillName}/1.0.0/archive.zip", + $"/api/v1/skills/{skillName}/1.0.0/references/guide.md", + $"/api/v1/subagents/{subAgentName}/1.0.0/agent.md" ]; foreach (var artifact in openArtifacts) @@ -178,7 +178,7 @@ public async Task SkillResourceDownload_WithTraversalPath_DoesNotEscape(string t await PublishResourcefulSkillAsync(skillName, ct); var response = await _fixture.HttpClient.GetAsync( - $"/skills/{skillName}/1.0.0/{traversalPath}", ct); + $"/api/v1/skills/{skillName}/1.0.0/{traversalPath}", ct); // A traversal path must never resolve to a real file. Resource lookup is // by exact stored relative-path equality, so this yields a 404 rather than diff --git a/tests/SkillServer.Integration.Tests/SkillServerClientNativeManifestTests.cs b/tests/SkillServer.Integration.Tests/SkillServerClientNativeManifestTests.cs index 868e960..9ea20f8 100644 --- a/tests/SkillServer.Integration.Tests/SkillServerClientNativeManifestTests.cs +++ b/tests/SkillServer.Integration.Tests/SkillServerClientNativeManifestTests.cs @@ -82,8 +82,8 @@ You are a client-tested sub-agent. var root = await _fixture.Client.GetManifestAsync(ct); Assert.NotNull(root); - Assert.Equal("/manifest/skills/index.json", root.Links.Skills.Href); - Assert.Equal("/manifest/subagents/index.json", root.Links.SubAgents.Href); + Assert.Equal("/api/v1/manifest/skills/index.json", root.Links.Skills.Href); + Assert.Equal("/api/v1/manifest/subagents/index.json", root.Links.SubAgents.Href); var skillIndex = await _fixture.Client.GetNativeSkillIndexAsync(root.Links.Skills, ct); Assert.NotNull(skillIndex); diff --git a/tests/SkillServer.Integration.Tests/SkillServerIntegrationTests.cs b/tests/SkillServer.Integration.Tests/SkillServerIntegrationTests.cs index 12c1325..dba21c1 100644 --- a/tests/SkillServer.Integration.Tests/SkillServerIntegrationTests.cs +++ b/tests/SkillServer.Integration.Tests/SkillServerIntegrationTests.cs @@ -102,7 +102,7 @@ This is a test skill. fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/markdown"); content.Add(fileContent, "file", "SKILL.md"); - var uploadResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content, ct); + var uploadResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/api/v1/skills", content, ct); Assert.Equal(HttpStatusCode.Created, uploadResponse.StatusCode); // List skills - should contain our skill (no auth required for reads) @@ -115,7 +115,7 @@ This is a test skill. Assert.Contains(index.Skills, s => s.Name == skillName); var indexSkill = Assert.Single(index.Skills, s => s.Name == skillName); Assert.Equal("skill-md", indexSkill.Type); - Assert.EndsWith($"/skills/{skillName}/1.0.0/SKILL.md", indexSkill.Url, StringComparison.Ordinal); + Assert.EndsWith($"/api/v1/skills/{skillName}/1.0.0/SKILL.md", indexSkill.Url, StringComparison.Ordinal); // Get skill versions var versions = await _fixture.Client.GetSkillVersionsAsync(skillName, ct); @@ -161,15 +161,15 @@ public async Task NativeManifest_SkillTraversal_ReturnsRfcArtifactValues() fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/markdown"); content.Add(fileContent, "file", "SKILL.md"); - var uploadResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content, ct); + var uploadResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/api/v1/skills", content, ct); Assert.Equal(HttpStatusCode.Created, uploadResponse.StatusCode); - var root = await _fixture.HttpClient.GetFromJsonAsync("/manifest.json", ct); + var root = await _fixture.HttpClient.GetFromJsonAsync("/api/v1/manifest.json", ct); Assert.NotNull(root); - Assert.Equal("/manifest.json", root.Links.Self.Href); + Assert.Equal("/api/v1/manifest.json", root.Links.Self.Href); Assert.Equal("/.well-known/agent-skills/index.json", root.Links.RfcSkills.Href); - Assert.Equal("/manifest/skills/index.json", root.Links.Skills.Href); - Assert.Equal("/manifest/subagents/index.json", root.Links.SubAgents.Href); + Assert.Equal("/api/v1/manifest/skills/index.json", root.Links.Skills.Href); + Assert.Equal("/api/v1/manifest/subagents/index.json", root.Links.SubAgents.Href); var skillIndex = await _fixture.HttpClient.GetFromJsonAsync( root.Links.Skills.Href, ct); @@ -182,7 +182,7 @@ public async Task NativeManifest_SkillTraversal_ReturnsRfcArtifactValues() Assert.NotNull(skillPage); var item = Assert.Single(skillPage.Items, i => i.Name == skillName); Assert.Equal("1.0.0", item.LatestVersion); - Assert.Equal($"/manifest/skills/{skillName}/index.json", item.Href); + Assert.Equal($"/api/v1/manifest/skills/{skillName}/index.json", item.Href); var identity = await _fixture.HttpClient.GetFromJsonAsync( item.Href, ct); @@ -196,7 +196,7 @@ public async Task NativeManifest_SkillTraversal_ReturnsRfcArtifactValues() Assert.Equal("skill-version", detail.Kind); Assert.NotNull(detail.RoutesToSubagent); Assert.Equal("technical-support-diagnostician", detail.RoutesToSubagent!.Name); - Assert.Equal("/manifest/subagents/technical-support-diagnostician/index.json", detail.RoutesToSubagent.Href); + Assert.Equal("/api/v1/manifest/subagents/technical-support-diagnostician/index.json", detail.RoutesToSubagent.Href); var rfcIndex = await _fixture.Client.GetRfcIndexAsync(ct); Assert.NotNull(rfcIndex); @@ -230,7 +230,7 @@ You are a technical support diagnostician. """; using var content = CreateSubAgentUpload(subAgentName, "1.0.0", agentMd); - var uploadResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/subagents", content, ct); + var uploadResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/api/v1/subagents", content, ct); Assert.Equal(HttpStatusCode.Created, uploadResponse.StatusCode); var upload = await uploadResponse.Content.ReadFromJsonAsync(ct); @@ -238,14 +238,14 @@ You are a technical support diagnostician. Assert.Equal(subAgentName, upload.Name); Assert.Equal("1.0.0", upload.Version); Assert.StartsWith("sha256:", upload.Sha256); - Assert.EndsWith($"/subagents/{subAgentName}/1.0.0/agent.md", upload.Url, StringComparison.Ordinal); + Assert.EndsWith($"/api/v1/subagents/{subAgentName}/1.0.0/agent.md", upload.Url, StringComparison.Ordinal); - var list = await _fixture.HttpClient.GetFromJsonAsync>("/subagents", ct); + var list = await _fixture.HttpClient.GetFromJsonAsync>("/api/v1/subagents", ct); Assert.NotNull(list); Assert.Contains(list, s => s.Name == subAgentName && s.LatestVersion == "1.0.0"); var versions = await _fixture.HttpClient.GetFromJsonAsync>( - $"/subagents/{subAgentName}", ct); + $"/api/v1/subagents/{subAgentName}", ct); Assert.NotNull(versions); var version = Assert.Single(versions); Assert.Equal("agent-md", version.Type); @@ -256,11 +256,11 @@ You are a technical support diagnostician. Assert.True(version.EmitStructuredFindings); var versionDetail = await _fixture.HttpClient.GetFromJsonAsync( - $"/subagents/{subAgentName}/1.0.0", ct); + $"/api/v1/subagents/{subAgentName}/1.0.0", ct); Assert.NotNull(versionDetail); Assert.Equal(upload.Sha256, versionDetail.Sha256); - var artifactResponse = await _fixture.HttpClient.GetAsync($"/subagents/{subAgentName}/1.0.0/agent.md", ct); + var artifactResponse = await _fixture.HttpClient.GetAsync($"/api/v1/subagents/{subAgentName}/1.0.0/agent.md", ct); Assert.Equal(HttpStatusCode.OK, artifactResponse.StatusCode); Assert.Equal("text/markdown", artifactResponse.Content.Headers.ContentType?.MediaType); @@ -292,12 +292,12 @@ You are a manifest-tested sub-agent. """; using var content = CreateSubAgentUpload(subAgentName, "1.0.0", agentMd); - var uploadResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/subagents", content, ct); + var uploadResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/api/v1/subagents", content, ct); Assert.Equal(HttpStatusCode.Created, uploadResponse.StatusCode); - var root = await _fixture.HttpClient.GetFromJsonAsync("/manifest.json", ct); + var root = await _fixture.HttpClient.GetFromJsonAsync("/api/v1/manifest.json", ct); Assert.NotNull(root); - Assert.Equal("/manifest/subagents/index.json", root.Links.SubAgents.Href); + Assert.Equal("/api/v1/manifest/subagents/index.json", root.Links.SubAgents.Href); var subAgentIndex = await _fixture.HttpClient.GetFromJsonAsync( root.Links.SubAgents.Href, ct); @@ -311,7 +311,7 @@ You are a manifest-tested sub-agent. Assert.Equal("subagent-page", subAgentPage.Kind); var item = Assert.Single(subAgentPage.Items, i => i.Name == subAgentName); Assert.Equal("1.0.0", item.LatestVersion); - Assert.Equal($"/manifest/subagents/{subAgentName}/index.json", item.Href); + Assert.Equal($"/api/v1/manifest/subagents/{subAgentName}/index.json", item.Href); var identity = await _fixture.HttpClient.GetFromJsonAsync( item.Href, ct); @@ -328,9 +328,9 @@ You are a manifest-tested sub-agent. Assert.Equal("1.0.0", detail.Version); Assert.Equal("agent-md", detail.Type); Assert.Equal("Native manifest sub-agent test.", detail.Description); - Assert.EndsWith($"/subagents/{subAgentName}/1.0.0/agent.md", detail.Url, StringComparison.Ordinal); + Assert.EndsWith($"/api/v1/subagents/{subAgentName}/1.0.0/agent.md", detail.Url, StringComparison.Ordinal); - var artifactResponse = await _fixture.HttpClient.GetAsync($"/subagents/{subAgentName}/1.0.0/agent.md", ct); + var artifactResponse = await _fixture.HttpClient.GetAsync($"/api/v1/subagents/{subAgentName}/1.0.0/agent.md", ct); Assert.Equal(HttpStatusCode.OK, artifactResponse.StatusCode); var artifactBytes = await artifactResponse.Content.ReadAsByteArrayAsync(ct); Assert.Equal(detail.Digest, ComputeSha256Digest(artifactBytes)); @@ -349,11 +349,11 @@ public async Task UploadSubAgent_DuplicateVersion_ReturnsConflict() var agentMd = $"---\nname: {subAgentName}\ndescription: Duplicate test\n---\nPrompt body"; using var first = CreateSubAgentUpload(subAgentName, "1.0.0", agentMd); - var firstResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/subagents", first, ct); + var firstResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/api/v1/subagents", first, ct); Assert.Equal(HttpStatusCode.Created, firstResponse.StatusCode); using var second = CreateSubAgentUpload(subAgentName, "1.0.0", agentMd); - var secondResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/subagents", second, ct); + var secondResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/api/v1/subagents", second, ct); Assert.Equal(HttpStatusCode.Conflict, secondResponse.StatusCode); } @@ -378,7 +378,7 @@ public async Task UploadSubAgent_InvalidFrontmatter_ReturnsBadRequest() var agentMd = $"---\nname: {testCase.Name}\n{testCase.Body}\n---\n{promptBody}"; using var content = CreateSubAgentUpload(testCase.Name, "1.0.0", agentMd); - var response = await _fixture.AuthenticatedHttpClient.PostAsync("/subagents", content, ct); + var response = await _fixture.AuthenticatedHttpClient.PostAsync("/api/v1/subagents", content, ct); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var error = await response.Content.ReadFromJsonAsync(ct); @@ -395,14 +395,14 @@ public async Task DeleteSubAgentVersion_RemovesVersion() var agentMd = $"---\nname: {subAgentName}\ndescription: Delete test\n---\nPrompt body"; using var content = CreateSubAgentUpload(subAgentName, "1.0.0", agentMd); - var uploadResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/subagents", content, ct); + var uploadResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/api/v1/subagents", content, ct); Assert.Equal(HttpStatusCode.Created, uploadResponse.StatusCode); - var deleteResponse = await _fixture.AuthenticatedHttpClient.DeleteAsync($"/subagents/{subAgentName}/1.0.0", ct); + var deleteResponse = await _fixture.AuthenticatedHttpClient.DeleteAsync($"/api/v1/subagents/{subAgentName}/1.0.0", ct); Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode); var versions = await _fixture.HttpClient.GetFromJsonAsync>( - $"/subagents/{subAgentName}", ct); + $"/api/v1/subagents/{subAgentName}", ct); Assert.NotNull(versions); Assert.Empty(versions); } @@ -431,7 +431,7 @@ public async Task GetBlob_ByDigest() fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/markdown"); content.Add(fileContent, "file", "SKILL.md"); - await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content, ct); + await _fixture.AuthenticatedHttpClient.PostAsync("/api/v1/skills", content, ct); // Get the version to find the digest var version = await _fixture.Client.GetVersionAsync(skillName, "1.0.0", ct); @@ -469,14 +469,14 @@ public async Task DeleteVersion_RemovesSkill() fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/markdown"); content.Add(fileContent, "file", "SKILL.md"); - await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content, ct); + await _fixture.AuthenticatedHttpClient.PostAsync("/api/v1/skills", content, ct); // Verify it exists var version = await _fixture.Client.GetVersionAsync(skillName, "1.0.0", ct); Assert.NotNull(version); // Delete it (requires auth) - var deleteResponse = await _fixture.AuthenticatedHttpClient.DeleteAsync($"/skills/{skillName}/1.0.0", ct); + var deleteResponse = await _fixture.AuthenticatedHttpClient.DeleteAsync($"/api/v1/skills/{skillName}/1.0.0", ct); Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode); // Verify it's gone @@ -511,7 +511,7 @@ public async Task Pagination_WorksCorrectly() fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/markdown"); content.Add(fileContent, "file", "SKILL.md"); - await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content, ct); + await _fixture.AuthenticatedHttpClient.PostAsync("/api/v1/skills", content, ct); } // Test pagination - get all skills then verify we can paginate @@ -537,7 +537,7 @@ public async Task UploadSkill_WithoutApiKey_Returns401() fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/markdown"); content.Add(fileContent, "file", "SKILL.md"); - var response = await _fixture.HttpClient.PostAsync("/skills", content, ct); + var response = await _fixture.HttpClient.PostAsync("/api/v1/skills", content, ct); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } @@ -546,7 +546,7 @@ public async Task DeleteSkill_WithoutApiKey_Returns401() { var ct = TestContext.Current.CancellationToken; - var response = await _fixture.HttpClient.DeleteAsync("/skills/nonexistent/1.0.0", ct); + var response = await _fixture.HttpClient.DeleteAsync("/api/v1/skills/nonexistent/1.0.0", ct); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } @@ -563,7 +563,7 @@ public async Task UploadSkill_WithInvalidApiKey_Returns403() fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/markdown"); content.Add(fileContent, "file", "SKILL.md"); - using var request = new HttpRequestMessage(HttpMethod.Post, "/skills"); + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/skills"); request.Content = content; request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "sk-invalid-key"); @@ -576,7 +576,7 @@ public async Task GetSkills_WithoutApiKey_Returns200() { var ct = TestContext.Current.CancellationToken; - var response = await _fixture.HttpClient.GetAsync("/skills", ct); + var response = await _fixture.HttpClient.GetAsync("/api/v1/skills", ct); Assert.Equal(HttpStatusCode.OK, response.StatusCode); } @@ -586,7 +586,7 @@ public async Task ApiKeyManagement_CreateListDelete_EndToEnd() var ct = TestContext.Current.CancellationToken; // Create a new key - var createResponse = await _fixture.AuthenticatedHttpClient.PostAsJsonAsync("/api-keys", + var createResponse = await _fixture.AuthenticatedHttpClient.PostAsJsonAsync("/api/v1/api-keys", new { label = "test-key" }, ct); Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); @@ -600,7 +600,7 @@ public async Task ApiKeyManagement_CreateListDelete_EndToEnd() Assert.StartsWith("sk-", created.Key); // List keys - var listResponse = await _fixture.AuthenticatedHttpClient.GetAsync("/api-keys", ct); + var listResponse = await _fixture.AuthenticatedHttpClient.GetAsync("/api/v1/api-keys", ct); Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); var keys = await listResponse.Content.ReadFromJsonAsync>(jsonOptions, ct); @@ -608,7 +608,7 @@ public async Task ApiKeyManagement_CreateListDelete_EndToEnd() Assert.Contains(keys, k => k.Label == "test-key"); // Delete the new key (not the bootstrap key) - var deleteResponse = await _fixture.AuthenticatedHttpClient.DeleteAsync($"/api-keys/{created.Id}", ct); + var deleteResponse = await _fixture.AuthenticatedHttpClient.DeleteAsync($"/api/v1/api-keys/{created.Id}", ct); Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode); } @@ -637,7 +637,7 @@ public async Task SearchSkills_ReturnsMatchingResults() fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/markdown"); content.Add(fileContent, "file", "SKILL.md"); - await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content, ct); + await _fixture.AuthenticatedHttpClient.PostAsync("/api/v1/skills", content, ct); // Search by description keyword var results = await _fixture.Client.SearchSkillsAsync("kubernetes", ct: ct); @@ -674,7 +674,7 @@ public async Task GetLatestVersion_ReturnsLatest() var fileContent1 = new ByteArrayContent(Encoding.UTF8.GetBytes(skillContent1)); fileContent1.Headers.ContentType = new MediaTypeHeaderValue("text/markdown"); content1.Add(fileContent1, "file", "SKILL.md"); - await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content1, ct); + await _fixture.AuthenticatedHttpClient.PostAsync("/api/v1/skills", content1, ct); // Upload v2.0.0 var skillContent2 = $""" @@ -692,7 +692,7 @@ public async Task GetLatestVersion_ReturnsLatest() var fileContent2 = new ByteArrayContent(Encoding.UTF8.GetBytes(skillContent2)); fileContent2.Headers.ContentType = new MediaTypeHeaderValue("text/markdown"); content2.Add(fileContent2, "file", "SKILL.md"); - await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content2, ct); + await _fixture.AuthenticatedHttpClient.PostAsync("/api/v1/skills", content2, ct); // Get latest - should be v2.0.0 var latest = await _fixture.Client.GetLatestVersionAsync(skillName, ct); @@ -705,7 +705,7 @@ public async Task GetLatestVersion_ReturnsLatest() public async Task GetLatestVersion_NotFound_Returns404() { var ct = TestContext.Current.CancellationToken; - var response = await _fixture.HttpClient.GetAsync("/skills/nonexistent-skill/latest", ct); + var response = await _fixture.HttpClient.GetAsync("/api/v1/skills/nonexistent-skill/latest", ct); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } @@ -731,7 +731,7 @@ public async Task CheckUpdates_ReturnsUpdateStatus() var fileContent1 = new ByteArrayContent(Encoding.UTF8.GetBytes(skillContent1)); fileContent1.Headers.ContentType = new MediaTypeHeaderValue("text/markdown"); content1.Add(fileContent1, "file", "SKILL.md"); - await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content1, ct); + await _fixture.AuthenticatedHttpClient.PostAsync("/api/v1/skills", content1, ct); // Upload v2.0.0 var skillContent2 = $""" @@ -749,7 +749,7 @@ public async Task CheckUpdates_ReturnsUpdateStatus() var fileContent2 = new ByteArrayContent(Encoding.UTF8.GetBytes(skillContent2)); fileContent2.Headers.ContentType = new MediaTypeHeaderValue("text/markdown"); content2.Add(fileContent2, "file", "SKILL.md"); - await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content2, ct); + await _fixture.AuthenticatedHttpClient.PostAsync("/api/v1/skills", content2, ct); // Check updates - asking about v1.0.0 should show update available var updates = await _fixture.Client.CheckUpdatesAsync([ @@ -773,7 +773,7 @@ public async Task ApiKeyManagement_WithoutAuth_Returns401() { var ct = TestContext.Current.CancellationToken; - var response = await _fixture.HttpClient.GetAsync("/api-keys", ct); + var response = await _fixture.HttpClient.GetAsync("/api/v1/api-keys", ct); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } @@ -801,7 +801,7 @@ public async Task UploadSkill_DuplicateVersion_Returns409() fileContent1.Headers.ContentType = new MediaTypeHeaderValue("text/markdown"); content1.Add(fileContent1, "file", "SKILL.md"); - var uploadResponse1 = await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content1, ct); + var uploadResponse1 = await _fixture.AuthenticatedHttpClient.PostAsync("/api/v1/skills", content1, ct); Assert.Equal(HttpStatusCode.Created, uploadResponse1.StatusCode); // Upload the same version again - should return 409 Conflict @@ -813,7 +813,7 @@ public async Task UploadSkill_DuplicateVersion_Returns409() fileContent2.Headers.ContentType = new MediaTypeHeaderValue("text/markdown"); content2.Add(fileContent2, "file", "SKILL.md"); - var uploadResponse2 = await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content2, ct); + var uploadResponse2 = await _fixture.AuthenticatedHttpClient.PostAsync("/api/v1/skills", content2, ct); Assert.Equal(HttpStatusCode.Conflict, uploadResponse2.StatusCode); // Verify the response body contains the duplicate_version error @@ -847,7 +847,7 @@ public async Task UploadSkill_DifferentVersions_AreAllowed() fileContent1.Headers.ContentType = new MediaTypeHeaderValue("text/markdown"); content1.Add(fileContent1, "file", "SKILL.md"); - var uploadResponse1 = await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content1, ct); + var uploadResponse1 = await _fixture.AuthenticatedHttpClient.PostAsync("/api/v1/skills", content1, ct); Assert.Equal(HttpStatusCode.Created, uploadResponse1.StatusCode); // Upload v2.0.0 - should succeed @@ -859,7 +859,7 @@ public async Task UploadSkill_DifferentVersions_AreAllowed() fileContent2.Headers.ContentType = new MediaTypeHeaderValue("text/markdown"); content2.Add(fileContent2, "file", "SKILL.md"); - var uploadResponse2 = await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content2, ct); + var uploadResponse2 = await _fixture.AuthenticatedHttpClient.PostAsync("/api/v1/skills", content2, ct); Assert.Equal(HttpStatusCode.Created, uploadResponse2.StatusCode); // Verify both versions exist @@ -891,7 +891,7 @@ public async Task SearchSkills_WithPorterStemming_MatchesStemmedTerms() fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/markdown"); content.Add(fileContent, "file", "SKILL.md"); - await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content, ct); + await _fixture.AuthenticatedHttpClient.PostAsync("/api/v1/skills", content, ct); // "closed deals" should match "close deal" via porter stemming var results = await _fixture.Client.SearchSkillsAsync("closed deals", ct: ct); @@ -968,7 +968,7 @@ Second section content. }); content.Add(new StringContent(resourceMetadata, Encoding.UTF8, "application/json"), "resourceMetadata"); - var uploadResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content, ct); + var uploadResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/api/v1/skills", content, ct); Assert.Equal(HttpStatusCode.Created, uploadResponse.StatusCode); var version = await _fixture.Client.GetVersionAsync(skillName, "1.0.0", ct); @@ -979,20 +979,20 @@ Second section content. Assert.Contains("# Resource Upload Test", downloadedSkill); var refResponse = await _fixture.HttpClient.GetAsync( - $"/skills/{skillName}/1.0.0/references/guide.md", ct); + $"/api/v1/skills/{skillName}/1.0.0/references/guide.md", ct); Assert.Equal(HttpStatusCode.OK, refResponse.StatusCode); var refBody = await refResponse.Content.ReadAsStringAsync(ct); Assert.Contains("# Guide", refBody); Assert.Contains("Section One", refBody); var scriptResponse = await _fixture.HttpClient.GetAsync( - $"/skills/{skillName}/1.0.0/scripts/setup.sh", ct); + $"/api/v1/skills/{skillName}/1.0.0/scripts/setup.sh", ct); Assert.Equal(HttpStatusCode.OK, scriptResponse.StatusCode); var scriptBody = await scriptResponse.Content.ReadAsStringAsync(ct); Assert.Contains("setup complete", scriptBody); var toolResponse = await _fixture.HttpClient.GetAsync( - $"/skills/{skillName}/1.0.0/tools/check", ct); + $"/api/v1/skills/{skillName}/1.0.0/tools/check", ct); Assert.Equal(HttpStatusCode.OK, toolResponse.StatusCode); var toolBody = await toolResponse.Content.ReadAsStringAsync(ct); Assert.Contains("tool check complete", toolBody); @@ -1002,13 +1002,13 @@ Second section content. var indexSkill = index.Skills.FirstOrDefault(s => s.Name == skillName); Assert.NotNull(indexSkill); Assert.Equal("archive", indexSkill!.Type); - Assert.EndsWith($"/skills/{skillName}/1.0.0/archive.zip", indexSkill.Url, StringComparison.Ordinal); + Assert.EndsWith($"/api/v1/skills/{skillName}/1.0.0/archive.zip", indexSkill.Url, StringComparison.Ordinal); Assert.NotEqual(version.Sha256, indexSkill.Digest); var skillMdVerified = await _fixture.Client.VerifyDigestAsync(skillName, "1.0.0", version.Sha256, ct); Assert.True(skillMdVerified); - var archiveResponse = await _fixture.HttpClient.GetAsync($"/skills/{skillName}/1.0.0/archive.zip", ct); + var archiveResponse = await _fixture.HttpClient.GetAsync($"/api/v1/skills/{skillName}/1.0.0/archive.zip", ct); Assert.Equal(HttpStatusCode.OK, archiveResponse.StatusCode); Assert.Equal("application/zip", archiveResponse.Content.Headers.ContentType?.MediaType); @@ -1016,7 +1016,7 @@ Second section content. Assert.Equal(indexSkill.Digest, ComputeSha256Digest(archiveBytes)); var nativeDetail = await _fixture.HttpClient.GetFromJsonAsync( - $"/manifest/skills/{skillName}/versions/1.0.0.json", ct); + $"/api/v1/manifest/skills/{skillName}/versions/1.0.0.json", ct); Assert.NotNull(nativeDetail); Assert.Equal(indexSkill.Type, nativeDetail.Artifact.Type); Assert.Equal(indexSkill.Url, nativeDetail.Artifact.Url); @@ -1068,7 +1068,7 @@ public async Task UploadSkillWithResources_WithoutResources_StillWorks() fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/markdown"); content.Add(fileContent, "file", "SKILL.md"); - var uploadResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content, ct); + var uploadResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/api/v1/skills", content, ct); Assert.Equal(HttpStatusCode.Created, uploadResponse.StatusCode); var version = await _fixture.Client.GetVersionAsync(skillName, "1.0.0", ct); @@ -1103,7 +1103,7 @@ public async Task UploadSkillWithResources_InvalidPath_ReturnsBadRequest() badFile.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); content.Add(badFile, "resources", "../etc/passwd"); - var uploadResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content, ct); + var uploadResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/api/v1/skills", content, ct); Assert.Equal(HttpStatusCode.BadRequest, uploadResponse.StatusCode); } @@ -1117,7 +1117,7 @@ public async Task UploadSkillWithResources_WindowsDrivePath_ReturnsBadRequest(st using var content = CreateResourceUploadContent(skillName, path, "exploit"); - var uploadResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content, ct); + var uploadResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/api/v1/skills", content, ct); Assert.Equal(HttpStatusCode.BadRequest, uploadResponse.StatusCode); } @@ -1134,7 +1134,7 @@ public async Task UploadSkillWithResources_DangerousUnixMode_ReturnsBadRequest() }); content.Add(new StringContent(resourceMetadata, Encoding.UTF8, "application/json"), "resourceMetadata"); - var uploadResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content, ct); + var uploadResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/api/v1/skills", content, ct); Assert.Equal(HttpStatusCode.BadRequest, uploadResponse.StatusCode); } @@ -1151,7 +1151,7 @@ public async Task UploadSkillWithResources_UnmatchedResourceMetadata_ReturnsBadR }); content.Add(new StringContent(resourceMetadata, Encoding.UTF8, "application/json"), "resourceMetadata"); - var uploadResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/skills", content, ct); + var uploadResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/api/v1/skills", content, ct); Assert.Equal(HttpStatusCode.BadRequest, uploadResponse.StatusCode); } From bbec1fd118347c5e4fd9661227072666b81f26af Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 6 Jul 2026 17:37:20 +0000 Subject: [PATCH 2/3] Implement native manifest with HATEOAS discovery and API versioning - Restructured manifest to use versioned API prefixes (/skills/v1/..., /subagents/v1/...) - Added version negotiation: client resolves best supported version from manifest - Added search links (skillSearch, subagentSearch) for discovery - Removed RFC feed from manifest (it exists independently) - Updated explicit index.json filenames for consistency - Added NativeManifestClient.GetNativeManifestAsync() and ResolveVersion() - Updated all integration tests, forward-compat tests, and CLI tests - Updated spec and README to match implementation Resolves 13 build errors from previous agent's incomplete changes. All 214 tests pass. --- README.md | 59 +++++++- docs/specs/native-manifest.md | 133 +++++++++++------- .../NativeManifestClient.cs | 44 +++++- .../NativeManifestModels.cs | 20 +-- src/SkillServer/Endpoints.cs | 26 ++-- src/SkillServer/Models/NativeManifest.cs | 21 ++- .../Models/SkillServerJsonContext.cs | 3 +- .../Services/NativeManifestGenerator.cs | 48 ++++--- .../DownloadSubAgentCommandTests.cs | 4 +- .../ClientForwardCompatTests.cs | 33 +++-- .../NativeSyncSecurityTests.cs | 6 +- .../SkillServerClientNativeManifestTests.cs | 10 +- .../SkillServerIntegrationTests.cs | 28 ++-- 13 files changed, 282 insertions(+), 153 deletions(-) diff --git a/README.md b/README.md index 6e076a0..79081ca 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ SkillServer implements Agent Skills standards and defines native extensions for |---------------|---------| | [Skill Packages](docs/specs/skills.md) | How to author and publish AgentSkills.io-compatible SkillServer skills. | | [Sub-Agent Packages](docs/specs/subagents.md) | How NetClaw sub-agent definitions are authored and how SkillServer will publish them. | -| [Native Manifest](docs/specs/native-manifest.md) | Planned non-RFC sync feed for skills, sub-agents, and future native resources. | +| [Native Manifest](docs/specs/native-manifest.md) | HATEOAS-style sync feed for skills, sub-agents, and future native resources with API version negotiation. | | [Security And Trust Model](docs/specs/security.md) | Trust boundaries, authentication, digest verification, and sync safety for native sync. | | [Sub-Agent Sync Epic](docs/epics/subagent-sync.md) | Requirements and proposed GitHub issue breakdown for native manifest and sub-agent sync. | @@ -70,7 +70,7 @@ Configuration is via environment variables or `appsettings.json`: | Endpoint | Description | |----------|-------------| | `GET /.well-known/agent-skills/index.json` | RFC-compliant skill index | -| `GET /manifest.json` | Planned NetClaw-native manifest; see [Native Manifest](docs/specs/native-manifest.md) | +| `GET /manifest.json` | Native manifest with API version negotiation; see [Native Manifest](docs/specs/native-manifest.md) | ### Skills @@ -174,9 +174,62 @@ var updates = await client.CheckUpdatesAsync([ See the [client library README](src/Netclaw.SkillClient/README.md) for full API documentation. +## Native Manifest + +The native manifest (`/manifest.json`) is a HATEOAS-style discovery endpoint for SkillServer-aware clients. It provides API version negotiation and linked navigation to skills and sub-agents. + +### Version Negotiation + +The manifest declares available API versions. Clients negotiate to the most recent compatible version: + +```json +{ + "apiVersion": "v1", + "versions": { + "v1": { + "skills": { "href": "/skills/v1/index.json" }, + "subagents": { "href": "/subagents/v1/index.json" }, + "skillSearch": { "href": "/api/v1/skills" }, + "subagentSearch": { "href": "/api/v1/subagents" } + } + } +} +``` + +Clients follow links to traverse collections. The server owns pagination boundaries — clients never construct page URLs. + +### Search + +The manifest exposes search endpoints for discovery: + +```text +/api/v1/skills?q={query}&skip={skip}&take={take} +/api/v1/subagents?q={query}&skip={skip}&take={take} +``` + +Search is the primary discovery mechanism. Full enumeration via the collection index is also supported. + +### Client Library + +The `NativeManifestClient` class provides version-aware manifest access: + +```csharp +using var manifestClient = new NativeManifestClient("http://localhost:8080"); + +// Fetch manifest with automatic version negotiation +var manifest = await manifestClient.GetNativeManifestAsync(); + +// Resolve best supported version +var skillLinks = manifestClient.ResolveVersion(manifest, "v1"); + +// Browse skills +var skillIndex = await manifestClient.GetNativeSkillIndexAsync(skillLinks); +var skillDetail = await manifestClient.GetNativeSkillDetailAsync(skillLinks, "my-skill", "1.0.0"); +``` + ## NetClaw Integration -Current NetClaw feed sync uses the RFC skill discovery endpoint. The planned native manifest will add richer skill metadata and sub-agent sync. +Current NetClaw feed sync uses the RFC skill discovery endpoint. The native manifest provides richer skill metadata, sub-agent sync, and API version negotiation. Add SkillServer as a skill source using the configured feed URL for your NetClaw version: diff --git a/docs/specs/native-manifest.md b/docs/specs/native-manifest.md index 1938ae7..43f440d 100644 --- a/docs/specs/native-manifest.md +++ b/docs/specs/native-manifest.md @@ -1,24 +1,36 @@ # Native Manifest Specification -Status: target design. `/manifest.json` is documented in README today but is not implemented in the current server route map. +Status: Implemented. `/manifest.json` is the root discovery endpoint for SkillServer-aware clients. -The native manifest is SkillServer's richer, non-RFC sync feed. It must not replace the Cloudflare Agent Skills Discovery RFC feed. Instead, it provides a linked catalog for SkillServer-aware clients that need pagination, version history, sub-agent definitions, and additional metadata. +The native manifest is SkillServer's versioned, HATEOAS-style sync feed. It must not replace the Cloudflare Agent Skills Discovery RFC feed (`/.well-known/agent-skills/index.json`). Instead, it provides a linked catalog for SkillServer-aware clients that need version negotiation, search, version history, sub-agent definitions, and additional metadata. ## Goals -- Keep `/.well-known/agent-skills/index.json` as the standards-compatible skill discovery projection. - Add `/manifest.json` as a native entry point for SkillServer-aware clients. -- Use HATEOAS-style links with path-based pagination. -- Reuse the RFC skill artifact shape for every skill version. -- Publish sub-agents as first-class native resources, outside the RFC skill feed. -- Let clients follow links instead of constructing pagination URLs. +- Use HATEOAS-style links with versioned path prefixes. +- Support API version negotiation: client picks the most recent version it supports. +- Expose search endpoints for discovery without full enumeration. +- Publish sub-agents as first-class native resources. +- Let clients follow links instead of constructing URLs. ## Non-Goals - Do not list sub-agents in the Cloudflare RFC skill feed. +- Do not include the RFC feed in the native manifest (it exists independently). - Do not create a separate skill install format for native clients. - Do not define a general transitive dependency resolver in the first version. -- Do not require clients to understand query-string cursors. + +## Version Negotiation + +The manifest declares which API versions are available. Clients should: + +1. Know which versions they support (e.g., `["v1"]`). +2. Fetch `/manifest.json`. +3. Find the most recent version from the intersection of client-supported and server-available versions. +4. Use that version's links for all subsequent requests. +5. If no supported version is found, throw `NotSupportedException` or fall back to the oldest available version. + +When a new API version ships, older clients continue to work against their supported version. New clients automatically use the newest version they understand. ## Endpoint Shape @@ -28,40 +40,45 @@ The stable entry point is: GET /manifest.json ``` -The manifest tree uses path prefixes for collection and page navigation: +The manifest tree uses versioned path prefixes for collection and page navigation: ```text /manifest.json -/manifest/skills/index.json -/manifest/skills/pages/a-f.json -/manifest/skills/{skillName}/index.json -/manifest/skills/{skillName}/versions/{version}.json -/manifest/subagents/index.json -/manifest/subagents/pages/a-f.json -/manifest/subagents/{subagentName}/index.json -/manifest/subagents/{subagentName}/versions/{version}.json +/skills/v1/index.json +/skills/v1/pages/all.json +/skills/v1/{skillName}/index.json +/skills/v1/{skillName}/versions/{version}.json +/subagents/v1/index.json +/subagents/v1/pages/all.json +/subagents/v1/{subagentName}/index.json +/subagents/v1/{subagentName}/versions/{version}.json ``` Clients must follow returned `href` values. The path structure is readable and stable, but the server owns pagination boundaries. ## Root Manifest -`/manifest.json` links to the native collections exposed by the registry. +`/manifest.json` links to the native collections exposed by the registry, organized by API version. ```json { "$schema": "https://schemas.netclaw.dev/skillserver/manifest/0.1.0", - "generatedAt": "2026-06-29T12:00:00Z", - "links": { - "self": { "href": "/manifest.json" }, - "rfcSkills": { "href": "/.well-known/agent-skills/index.json" }, - "skills": { "href": "/manifest/skills/index.json" }, - "subagents": { "href": "/manifest/subagents/index.json" } + "apiVersion": "v1", + "versions": { + "v1": { + "self": { "href": "/manifest.json" }, + "skills": { "href": "/skills/v1/index.json" }, + "subagents": { "href": "/subagents/v1/index.json" }, + "skillSearch": { "href": "/api/v1/skills" }, + "subagentSearch": { "href": "/api/v1/subagents" } + } } } ``` -Clients that do not understand a linked collection should ignore it. +The `apiVersion` field indicates the currently recommended version. The `versions` object contains all available versions, keyed by version tag. When v2 ships, a `"v2": { ... }` entry is added. + +The RFC feed (`/.well-known/agent-skills/index.json`) is a separate standard and is not included in the manifest. ## Collection Index @@ -71,22 +88,18 @@ A collection index links to one or more server-defined pages. { "kind": "skill-index", "links": { - "self": { "href": "/manifest/skills/index.json" } + "self": { "href": "/skills/v1/index.json" } }, "pages": [ { - "range": "a-f", - "href": "/manifest/skills/pages/a-f.json" - }, - { - "range": "g-m", - "href": "/manifest/skills/pages/g-m.json" + "range": "all", + "href": "/skills/v1/pages/all.json" } ] } ``` -The `range` value is informational. Clients should not assume it is alphabetical or stable across registries. +The `range` value is informational. The server may replace a single "all" page with multiple pages in the future without breaking clients. ## Collection Page @@ -95,7 +108,7 @@ A collection page lists identities and coarse version bounds. ```json { "kind": "skill-page", - "range": "a-f", + "range": "all", "items": [ { "name": "support-triage", @@ -105,11 +118,11 @@ A collection page lists identities and coarse version bounds. "max": "1.2.0", "count": 4 }, - "href": "/manifest/skills/support-triage/index.json" + "href": "/skills/v1/support-triage/index.json" } ], "links": { - "self": { "href": "/manifest/skills/pages/a-f.json" } + "self": { "href": "/skills/v1/pages/all.json" } } } ``` @@ -130,11 +143,11 @@ An identity index lists all versions for one skill or sub-agent. "version": "1.2.0", "publishedAt": "2026-06-29T12:00:00Z", "digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - "href": "/manifest/skills/support-triage/versions/1.2.0.json" + "href": "/skills/v1/support-triage/versions/1.2.0.json" } ], "links": { - "self": { "href": "/manifest/skills/support-triage/index.json" } + "self": { "href": "/skills/v1/support-triage/index.json" } } } ``` @@ -142,7 +155,7 @@ An identity index lists all versions for one skill or sub-agent. ## Skill Version Detail Skill version details wrap the exact artifact object that the RFC feed exposes for the same skill version. -Resourceful skill artifacts use SkillServer's canonical `.zip` archive route, `/skills/{skillName}/{version}/archive.zip`. +Resourceful skill artifacts use SkillServer's canonical `.zip` archive route, `/api/v1/skills/{skillName}/{version}/archive.zip`. ```json { @@ -152,15 +165,15 @@ Resourceful skill artifacts use SkillServer's canonical `.zip` archive route, `/ "version": "1.2.0", "type": "archive", "description": "Triage support tickets and produce a concise diagnostic plan.", - "url": "/skills/support-triage/1.2.0/archive.zip", + "url": "/api/v1/skills/support-triage/1.2.0/archive.zip", "digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" }, "routesToSubagent": { "name": "technical-support-diagnostician", - "href": "/manifest/subagents/technical-support-diagnostician/index.json" + "href": "/subagents/v1/technical-support-diagnostician/index.json" }, "links": { - "self": { "href": "/manifest/skills/support-triage/versions/1.2.0.json" } + "self": { "href": "/skills/v1/support-triage/versions/1.2.0.json" } } } ``` @@ -178,26 +191,44 @@ Sub-agent version details use the same content-addressed artifact semantics, but "version": "1.0.0", "type": "agent-md", "description": "Diagnose incomplete technical support tickets and identify missing evidence.", - "url": "/subagents/technical-support-diagnostician/1.0.0/agent.md", + "url": "/api/v1/subagents/technical-support-diagnostician/1.0.0/agent.md", "digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "links": { - "self": { "href": "/manifest/subagents/technical-support-diagnostician/versions/1.0.0.json" } + "self": { "href": "/subagents/v1/technical-support-diagnostician/versions/1.0.0.json" } } } ``` +## Search + +The manifest exposes search endpoints that clients can use for discovery without full enumeration: + +```json +{ + "skillSearch": { "href": "/api/v1/skills" }, + "subagentSearch": { "href": "/api/v1/subagents" } +} +``` + +Clients append query parameters: +- `?q={query}` — full-text search +- `?skip={skip}&take={take}` — pagination + +The search endpoint is the primary discovery mechanism. Full enumeration via the collection index is supported but may be slower for large registries. + ## Sync Semantics Native clients should: 1. Fetch `/manifest.json`. -2. Follow collection links for supported resource kinds. -3. Follow page links to identity indexes. -4. Compare version and digest with local sync state. -5. Download changed artifacts from `url`. -6. Verify `digest` before installing any artifact. -7. Keep existing local artifacts when a network request fails. -8. Prune server-synced artifacts only after a successful collection sync confirms removal. +2. Resolve the best supported API version. +3. Follow collection links for supported resource kinds. +4. Follow page links to identity indexes. +5. Compare version and digest with local sync state. +6. Download changed artifacts from `url`. +7. Verify `digest` before installing any artifact. +8. Keep existing local artifacts when a network request fails. +9. Prune server-synced artifacts only after a successful collection sync confirms removal. Clients must ignore unsupported resource kinds and unknown fields. diff --git a/src/Netclaw.SkillClient/NativeManifestClient.cs b/src/Netclaw.SkillClient/NativeManifestClient.cs index 2fb8ec0..4f4e37f 100644 --- a/src/Netclaw.SkillClient/NativeManifestClient.cs +++ b/src/Netclaw.SkillClient/NativeManifestClient.cs @@ -9,17 +9,47 @@ namespace Netclaw.SkillClient; public sealed partial class SkillServerClient { + private static readonly string[] SupportedVersions = ["v1"]; + private NativeVersionLinks? _resolvedLinks; + public async Task GetManifestAsync(CancellationToken ct = default) { return await _httpClient.GetFromJsonAsync( - Api("manifest.json"), + "/manifest.json", SkillServerClientJsonContext.Default.NativeRootManifest, ct); } + /// + /// Resolves the best supported API version from the manifest. + /// Client knows which versions it supports, picks the most recent + /// version the server also supports. + /// + public async Task ResolveVersionAsync(CancellationToken ct = default) + { + if (_resolvedLinks is not null) + return _resolvedLinks; + + var manifest = await GetManifestAsync(ct) + ?? throw new InvalidOperationException("Failed to fetch manifest from server."); + + foreach (var version in SupportedVersions.Reverse()) + { + if (manifest.Versions.TryGetValue(version, out var links)) + { + _resolvedLinks = links; + return links; + } + } + + throw new NotSupportedException( + $"Server supports [{string.Join(", ", manifest.Versions.Keys)}] " + + $"but client supports [{string.Join(", ", SupportedVersions)}]."); + } + public Task GetNativeSkillIndexAsync(CancellationToken ct = default) { - return GetNativeSkillIndexByHrefAsync(Api("manifest/skills/index.json"), ct); + return GetNativeSkillIndexByHrefAsync("/skills/v1/index.json", ct); } public Task GetNativeSkillIndexAsync( @@ -66,7 +96,7 @@ public sealed partial class SkillServerClient public Task GetNativeSkillIdentityAsync(string name, CancellationToken ct = default) { return GetNativeSkillIdentityByHrefAsync( - $"manifest/skills/{Uri.EscapeDataString(name)}/index.json", + $"/skills/v1/{Uri.EscapeDataString(name)}/index.json", ct); } @@ -93,7 +123,7 @@ public sealed partial class SkillServerClient CancellationToken ct = default) { return GetNativeSkillVersionByHrefAsync( - $"manifest/skills/{Uri.EscapeDataString(name)}/versions/{Uri.EscapeDataString(version)}.json", + $"/skills/v1/{Uri.EscapeDataString(name)}/versions/{Uri.EscapeDataString(version)}.json", ct); } @@ -109,7 +139,7 @@ public sealed partial class SkillServerClient public Task GetNativeSubAgentIndexAsync(CancellationToken ct = default) { - return GetNativeSubAgentIndexByHrefAsync(Api("manifest/subagents/index.json"), ct); + return GetNativeSubAgentIndexByHrefAsync("/subagents/v1/index.json", ct); } public Task GetNativeSubAgentIndexAsync( @@ -158,7 +188,7 @@ public sealed partial class SkillServerClient CancellationToken ct = default) { return GetNativeSubAgentIdentityByHrefAsync( - $"manifest/subagents/{Uri.EscapeDataString(name)}/index.json", + $"/subagents/v1/{Uri.EscapeDataString(name)}/index.json", ct); } @@ -185,7 +215,7 @@ public sealed partial class SkillServerClient CancellationToken ct = default) { return GetNativeSubAgentVersionByHrefAsync( - $"manifest/subagents/{Uri.EscapeDataString(name)}/versions/{Uri.EscapeDataString(version)}.json", + $"/subagents/v1/{Uri.EscapeDataString(name)}/versions/{Uri.EscapeDataString(version)}.json", ct); } diff --git a/src/Netclaw.SkillClient/NativeManifestModels.cs b/src/Netclaw.SkillClient/NativeManifestModels.cs index 5e81521..844a9bb 100644 --- a/src/Netclaw.SkillClient/NativeManifestModels.cs +++ b/src/Netclaw.SkillClient/NativeManifestModels.cs @@ -19,22 +19,22 @@ public sealed record NativeManifestSelfLinks public NativeManifestLink Self { get; init; } = new(); } -public sealed record NativeRootManifestLinks +public sealed record NativeVersionLinks { [JsonPropertyName("self")] public NativeManifestLink Self { get; init; } = new(); - [JsonPropertyName("rfcSkills")] - public NativeManifestLink RfcSkills { get; init; } = new(); - [JsonPropertyName("skills")] public NativeManifestLink Skills { get; init; } = new(); [JsonPropertyName("subagents")] public NativeManifestLink SubAgents { get; init; } = new(); - [JsonPropertyName("apiBase")] - public NativeManifestLink ApiBase { get; init; } = new(); + [JsonPropertyName("skillSearch")] + public NativeManifestLink SkillSearch { get; init; } = new(); + + [JsonPropertyName("subagentSearch")] + public NativeManifestLink SubAgentSearch { get; init; } = new(); } public sealed record NativeRootManifest @@ -42,11 +42,11 @@ public sealed record NativeRootManifest [JsonPropertyName("$schema")] public string Schema { get; init; } = ""; - [JsonPropertyName("generatedAt")] - public DateTimeOffset GeneratedAt { get; init; } + [JsonPropertyName("apiVersion")] + public string ApiVersion { get; init; } = ""; - [JsonPropertyName("links")] - public NativeRootManifestLinks Links { get; init; } = new(); + [JsonPropertyName("versions")] + public Dictionary Versions { get; init; } = new(); } public sealed record NativeManifestPageLink diff --git a/src/SkillServer/Endpoints.cs b/src/SkillServer/Endpoints.cs index 44e3b00..995086e 100644 --- a/src/SkillServer/Endpoints.cs +++ b/src/SkillServer/Endpoints.cs @@ -38,7 +38,9 @@ private static void MapDiscoveryEndpoints(this WebApplication app) private static void MapManifestEndpoints(this WebApplication app) { - app.MapGet("/api/v1/manifest.json", async ( + // Root manifest — discovery entry point for HATEOAS clients + // Clients hit /manifest.json first, read versions dict, then follow resource links + app.MapGet("/manifest.json", async ( NativeManifestGenerator manifestGenerator, CancellationToken ct) => { @@ -46,9 +48,10 @@ private static void MapManifestEndpoints(this WebApplication app) return Results.Json(manifest, SkillServerJsonContext.Default.NativeRootManifest); }); - var manifest = app.MapGroup("/api/v1/manifest"); + // Skill manifest endpoints — /skills/v1/* + var skillManifest = app.MapGroup("/skills/v1"); - manifest.MapGet("/skills/index.json", async ( + skillManifest.MapGet("/index.json", async ( NativeManifestGenerator manifestGenerator, CancellationToken ct) => { @@ -56,7 +59,7 @@ private static void MapManifestEndpoints(this WebApplication app) return Results.Json(index, SkillServerJsonContext.Default.NativeSkillCollectionIndex); }); - manifest.MapGet("/skills/pages/{page}.json", async ( + skillManifest.MapGet("/pages/{page}.json", async ( string page, NativeManifestGenerator manifestGenerator, CancellationToken ct) => @@ -67,7 +70,7 @@ private static void MapManifestEndpoints(this WebApplication app) : Results.Json(skillPage, SkillServerJsonContext.Default.NativeSkillCollectionPage); }); - manifest.MapGet("/skills/{skillName}/index.json", async ( + skillManifest.MapGet("/{skillName}/index.json", async ( string skillName, NativeManifestGenerator manifestGenerator, CancellationToken ct) => @@ -78,7 +81,7 @@ private static void MapManifestEndpoints(this WebApplication app) : Results.Json(skill, SkillServerJsonContext.Default.NativeSkillIdentityIndex); }); - manifest.MapGet("/skills/{skillName}/versions/{version}.json", async ( + skillManifest.MapGet("/{skillName}/versions/{version}.json", async ( string skillName, string version, NativeManifestGenerator manifestGenerator, @@ -90,7 +93,10 @@ private static void MapManifestEndpoints(this WebApplication app) : Results.Json(skillVersion, SkillServerJsonContext.Default.NativeSkillVersionDetail); }); - manifest.MapGet("/subagents/index.json", async ( + // Subagent manifest endpoints — /subagents/v1/* + var subagentManifest = app.MapGroup("/subagents/v1"); + + subagentManifest.MapGet("/index.json", async ( NativeManifestGenerator manifestGenerator, CancellationToken ct) => { @@ -98,7 +104,7 @@ private static void MapManifestEndpoints(this WebApplication app) return Results.Json(index, SkillServerJsonContext.Default.NativeSubAgentCollectionIndex); }); - manifest.MapGet("/subagents/pages/{page}.json", async ( + subagentManifest.MapGet("/pages/{page}.json", async ( string page, NativeManifestGenerator manifestGenerator, CancellationToken ct) => @@ -109,7 +115,7 @@ private static void MapManifestEndpoints(this WebApplication app) : Results.Json(subAgentPage, SkillServerJsonContext.Default.NativeSubAgentCollectionPage); }); - manifest.MapGet("/subagents/{subAgentName}/index.json", async ( + subagentManifest.MapGet("/{subAgentName}/index.json", async ( string subAgentName, NativeManifestGenerator manifestGenerator, CancellationToken ct) => @@ -120,7 +126,7 @@ private static void MapManifestEndpoints(this WebApplication app) : Results.Json(subAgent, SkillServerJsonContext.Default.NativeSubAgentIdentityIndex); }); - manifest.MapGet("/subagents/{subAgentName}/versions/{version}.json", async ( + subagentManifest.MapGet("/{subAgentName}/versions/{version}.json", async ( string subAgentName, string version, NativeManifestGenerator manifestGenerator, diff --git a/src/SkillServer/Models/NativeManifest.cs b/src/SkillServer/Models/NativeManifest.cs index 8ea0193..ecacaba 100644 --- a/src/SkillServer/Models/NativeManifest.cs +++ b/src/SkillServer/Models/NativeManifest.cs @@ -19,22 +19,22 @@ public sealed record NativeManifestSelfLinks public required NativeManifestLink Self { get; init; } } -public sealed record NativeRootManifestLinks +public sealed record NativeVersionLinks { [JsonPropertyName("self")] public required NativeManifestLink Self { get; init; } - [JsonPropertyName("rfcSkills")] - public required NativeManifestLink RfcSkills { get; init; } - [JsonPropertyName("skills")] public required NativeManifestLink Skills { get; init; } [JsonPropertyName("subagents")] public required NativeManifestLink SubAgents { get; init; } - [JsonPropertyName("apiBase")] - public required NativeManifestLink ApiBase { get; init; } + [JsonPropertyName("skillSearch")] + public required NativeManifestLink SkillSearch { get; init; } + + [JsonPropertyName("subagentSearch")] + public required NativeManifestLink SubAgentSearch { get; init; } } public sealed record NativeRootManifest @@ -42,14 +42,11 @@ public sealed record NativeRootManifest [JsonPropertyName("$schema")] public string Schema { get; init; } = "https://schemas.netclaw.dev/skillserver/manifest/0.1.0"; - [JsonPropertyName("generatedAt")] - public required DateTimeOffset GeneratedAt { get; init; } - [JsonPropertyName("apiVersion")] - public string? ApiVersion { get; init; } + public required string ApiVersion { get; init; } - [JsonPropertyName("links")] - public required NativeRootManifestLinks Links { get; init; } + [JsonPropertyName("versions")] + public required Dictionary Versions { get; init; } } public sealed record NativeManifestPageLink diff --git a/src/SkillServer/Models/SkillServerJsonContext.cs b/src/SkillServer/Models/SkillServerJsonContext.cs index fa0b471..2482d2d 100644 --- a/src/SkillServer/Models/SkillServerJsonContext.cs +++ b/src/SkillServer/Models/SkillServerJsonContext.cs @@ -1,4 +1,4 @@ -// ----------------------------------------------------------------------- +// ----------------------------------------------------------------------- // // Copyright (C) 2026 - 2026 Petabridge, LLC // @@ -34,6 +34,7 @@ namespace SkillServer.Models; [JsonSerializable(typeof(IReadOnlyList))] [JsonSerializable(typeof(HealthResponse))] [JsonSerializable(typeof(NativeRootManifest))] +[JsonSerializable(typeof(NativeVersionLinks))] [JsonSerializable(typeof(NativeSkillCollectionIndex))] [JsonSerializable(typeof(NativeSkillCollectionPage))] [JsonSerializable(typeof(NativeSkillIdentityIndex))] diff --git a/src/SkillServer/Services/NativeManifestGenerator.cs b/src/SkillServer/Services/NativeManifestGenerator.cs index 9b7a8f3..8f232cc 100644 --- a/src/SkillServer/Services/NativeManifestGenerator.cs +++ b/src/SkillServer/Services/NativeManifestGenerator.cs @@ -31,15 +31,19 @@ public Task GenerateRootAsync(CancellationToken ct = default { var manifest = new NativeRootManifest { - GeneratedAt = DateTimeOffset.UtcNow, ApiVersion = "v1", - Links = new NativeRootManifestLinks + Versions = new Dictionary { - Self = Link("/api/v1/manifest.json"), - RfcSkills = Link("/.well-known/agent-skills/index.json"), - Skills = Link("/api/v1/manifest/skills/index.json"), - SubAgents = Link("/api/v1/manifest/subagents/index.json"), - ApiBase = Link("/api/v1") + { + "v1", new NativeVersionLinks + { + Self = Link("/manifest.json"), + Skills = Link("/skills/v1/index.json"), + SubAgents = Link("/subagents/v1/index.json"), + SkillSearch = Link("/api/v1/skills"), + SubAgentSearch = Link("/api/v1/subagents") + } + } } }; @@ -50,11 +54,11 @@ public Task GenerateSkillIndexAsync(CancellationToke { var index = new NativeSkillCollectionIndex { - Links = SelfLinks("/api/v1/manifest/skills/index.json"), + Links = SelfLinks("/skills/v1/index.json"), Pages = [new NativeManifestPageLink { Range = SkillsPageRange, - Href = "/api/v1/manifest/skills/pages/all.json" + Href = "/skills/v1/pages/all.json" }] }; @@ -86,7 +90,7 @@ public Task GenerateSkillIndexAsync(CancellationToke Max = latest.Version, Count = versions.Count }, - Href = $"/api/v1/manifest/skills/{latest.SkillName}/index.json" + Href = $"/skills/v1/{latest.SkillName}/index.json" }); } @@ -94,7 +98,7 @@ public Task GenerateSkillIndexAsync(CancellationToke { Range = SkillsPageRange, Items = items, - Links = SelfLinks("/api/v1/manifest/skills/pages/all.json") + Links = SelfLinks("/skills/v1/pages/all.json") }; } @@ -118,9 +122,9 @@ public Task GenerateSkillIndexAsync(CancellationToke Version = v.Version, PublishedAt = v.PublishedAt, Digest = Sha256Digest.Create(v.ArtifactSha256).Value, - Href = $"/api/v1/manifest/skills/{skill.Name}/versions/{v.Version}.json" + Href = $"/skills/v1/{skill.Name}/versions/{v.Version}.json" }).ToList(), - Links = SelfLinks($"/api/v1/manifest/skills/{skill.Name}/index.json") + Links = SelfLinks($"/skills/v1/{skill.Name}/index.json") }; } @@ -142,9 +146,9 @@ public Task GenerateSkillIndexAsync(CancellationToke : new NativeSubagentRoute { Name = skillVersion.RoutesToSubagent, - Href = $"/api/v1/manifest/subagents/{skillVersion.RoutesToSubagent}/index.json" + Href = $"/subagents/v1/{skillVersion.RoutesToSubagent}/index.json" }, - Links = SelfLinks($"/api/v1/manifest/skills/{skill.Name}/versions/{skillVersion.Version}.json") + Links = SelfLinks($"/skills/v1/{skill.Name}/versions/{skillVersion.Version}.json") }; } @@ -152,11 +156,11 @@ public Task GenerateSubAgentIndexAsync(Cancellati { var index = new NativeSubAgentCollectionIndex { - Links = SelfLinks("/api/v1/manifest/subagents/index.json"), + Links = SelfLinks("/subagents/v1/index.json"), Pages = [new NativeManifestPageLink { Range = SubAgentsPageRange, - Href = "/api/v1/manifest/subagents/pages/all.json" + Href = "/subagents/v1/pages/all.json" }] }; @@ -188,7 +192,7 @@ public Task GenerateSubAgentIndexAsync(Cancellati Max = latest.Version, Count = versions.Count }, - Href = $"/api/v1/manifest/subagents/{latest.SubAgentName}/index.json" + Href = $"/subagents/v1/{latest.SubAgentName}/index.json" }); } @@ -196,7 +200,7 @@ public Task GenerateSubAgentIndexAsync(Cancellati { Range = SubAgentsPageRange, Items = items, - Links = SelfLinks("/api/v1/manifest/subagents/pages/all.json") + Links = SelfLinks("/subagents/v1/pages/all.json") }; } @@ -220,9 +224,9 @@ public Task GenerateSubAgentIndexAsync(Cancellati Version = v.Version, PublishedAt = v.PublishedAt, Digest = Sha256Digest.Create(v.Sha256).Value, - Href = $"/api/v1/manifest/subagents/{subAgent.Name}/versions/{v.Version}.json" + Href = $"/subagents/v1/{subAgent.Name}/versions/{v.Version}.json" }).ToList(), - Links = SelfLinks($"/api/v1/manifest/subagents/{subAgent.Name}/index.json") + Links = SelfLinks($"/subagents/v1/{subAgent.Name}/index.json") }; } @@ -244,7 +248,7 @@ public Task GenerateSubAgentIndexAsync(Cancellati Description = subAgentVersion.Description, Url = $"{baseUrl}/api/v1/subagents/{subAgent.Name}/{subAgentVersion.Version}/agent.md", Digest = Sha256Digest.Create(subAgentVersion.Sha256).Value, - Links = SelfLinks($"/api/v1/manifest/subagents/{subAgent.Name}/versions/{subAgentVersion.Version}.json") + Links = SelfLinks($"/subagents/v1/{subAgent.Name}/versions/{subAgentVersion.Version}.json") }; } diff --git a/tests/Netclaw.SkillServer.Cli.Tests/DownloadSubAgentCommandTests.cs b/tests/Netclaw.SkillServer.Cli.Tests/DownloadSubAgentCommandTests.cs index 43f81d2..92cfd6f 100644 --- a/tests/Netclaw.SkillServer.Cli.Tests/DownloadSubAgentCommandTests.cs +++ b/tests/Netclaw.SkillServer.Cli.Tests/DownloadSubAgentCommandTests.cs @@ -50,7 +50,7 @@ public async Task ExecuteAsync_DownloadsVerifiedArtifactToDestination() Assert.Equal(0, exitCode); Assert.Equal(agentMd, await File.ReadAllTextAsync(destination, TestContext.Current.CancellationToken)); - Assert.Equal("/api/v1/manifest/subagents/support-agent/versions/1.0.0.json", handler.Requests[0].Path); + Assert.Equal("/subagents/v1/support-agent/versions/1.0.0.json", handler.Requests[0].Path); Assert.Equal("/api/v1/subagents/support-agent/1.0.0/agent.md", handler.Requests[1].Path); } @@ -121,7 +121,7 @@ public async Task ExecuteAsync_DigestMismatch_DoesNotLeaveDestinationFile() { Self = new NativeManifestLink { - Href = "/api/v1/manifest/subagents/support-agent/versions/1.0.0.json" + Href = "/subagents/v1/support-agent/versions/1.0.0.json" } } }; diff --git a/tests/SkillServer.Integration.Tests/ClientForwardCompatTests.cs b/tests/SkillServer.Integration.Tests/ClientForwardCompatTests.cs index 6eeaa49..7c6128d 100644 --- a/tests/SkillServer.Integration.Tests/ClientForwardCompatTests.cs +++ b/tests/SkillServer.Integration.Tests/ClientForwardCompatTests.cs @@ -46,14 +46,17 @@ public async Task GetManifest_WithUnknownLinkAndTopLevelField_IsTolerated() const string json = """ { "$schema": "https://netclaw.dev/manifest/v1", - "generatedAt": "2026-07-03T00:00:00Z", + "apiVersion": "v1", "futureTopLevelField": { "anything": [1, 2, 3] }, - "links": { - "self": { "href": "/api/v1/manifest.json" }, - "rfcSkills": { "href": "/.well-known/agent-skills/index.json" }, - "skills": { "href": "/api/v1/manifest/skills/index.json" }, - "subagents": { "href": "/api/v1/manifest/subagents/index.json" }, - "futureCollection": { "href": "/api/v1/manifest/future/index.json" } + "versions": { + "v1": { + "self": { "href": "/manifest.json" }, + "skills": { "href": "/skills/v1/index.json" }, + "subagents": { "href": "/subagents/v1/index.json" }, + "skillSearch": { "href": "/api/v1/skills" }, + "subagentSearch": { "href": "/api/v1/subagents" }, + "futureCollection": { "href": "/api/v1/future/index.json" } + } } } """; @@ -64,8 +67,8 @@ public async Task GetManifest_WithUnknownLinkAndTopLevelField_IsTolerated() var manifest = await client.GetManifestAsync(ct); Assert.NotNull(manifest); - Assert.Equal("/api/v1/manifest/skills/index.json", manifest.Links.Skills.Href); - Assert.Equal("/api/v1/manifest/subagents/index.json", manifest.Links.SubAgents.Href); + Assert.Equal("/skills/v1/index.json", manifest.Versions["v1"].Skills.Href); + Assert.Equal("/subagents/v1/index.json", manifest.Versions["v1"].SubAgents.Href); } [Fact] @@ -81,25 +84,25 @@ public async Task GetNativeSkillPage_WithUnknownKindAndExtraFields_IsTolerated() "name": "example-skill", "latestVersion": "1.0.0", "versionRange": { "min": "1.0.0", "max": "1.0.0", "count": 1 }, - "href": "/api/v1/manifest/skills/example-skill/index.json", + "href": "/skills/v1/example-skill/index.json", "futureItemField": "ignored" } ], - "links": { "self": { "href": "/api/v1/manifest/skills/pages/0.json" } } + "links": { "self": { "href": "/skills/v1/pages/0.json" } } } """; using var client = CreateClient(json); var ct = TestContext.Current.CancellationToken; - var page = await client.GetNativeSkillPageAsync("manifest/skills/pages/0.json", ct); + var page = await client.GetNativeSkillPageAsync("skills/v1/pages/0.json", ct); Assert.NotNull(page); // Unknown kind values round-trip as opaque strings instead of failing. Assert.Equal("future-skill-page-kind", page.Kind); var item = Assert.Single(page.Items); Assert.Equal("example-skill", item.Name); - Assert.Equal("/api/v1/manifest/skills/example-skill/index.json", item.Href); + Assert.Equal("/skills/v1/example-skill/index.json", item.Href); } [Fact] @@ -118,7 +121,7 @@ public async Task GetNativeSkillVersion_WithUnknownArtifactType_IsTolerated() "futureArtifactField": 42 }, "routesToSubagent": null, - "links": { "self": { "href": "/api/v1/manifest/skills/example-skill/versions/1.0.0.json" } } + "links": { "self": { "href": "/skills/v1/example-skill/versions/1.0.0.json" } } } """; @@ -126,7 +129,7 @@ public async Task GetNativeSkillVersion_WithUnknownArtifactType_IsTolerated() var ct = TestContext.Current.CancellationToken; var detail = await client.GetNativeSkillVersionByHrefAsync( - "manifest/skills/example-skill/versions/1.0.0.json", ct); + "skills/v1/example-skill/versions/1.0.0.json", ct); Assert.NotNull(detail); // An unrecognized artifact type is surfaced verbatim so a caller can skip diff --git a/tests/SkillServer.Integration.Tests/NativeSyncSecurityTests.cs b/tests/SkillServer.Integration.Tests/NativeSyncSecurityTests.cs index 8a44816..4e822bc 100644 --- a/tests/SkillServer.Integration.Tests/NativeSyncSecurityTests.cs +++ b/tests/SkillServer.Integration.Tests/NativeSyncSecurityTests.cs @@ -121,9 +121,9 @@ public async Task DeleteSubAgent_WithoutApiKey_Returns401() // ---- Task 4: manifest + artifact reads stay open (auth is enabled) --- [Theory] - [InlineData("/api/v1/manifest.json")] - [InlineData("/api/v1/manifest/skills/index.json")] - [InlineData("/api/v1/manifest/subagents/index.json")] + [InlineData("/manifest.json")] + [InlineData("/skills/v1/index.json")] + [InlineData("/subagents/v1/index.json")] public async Task ManifestEndpoints_AreReadableWithoutAuth(string path) { var ct = TestContext.Current.CancellationToken; diff --git a/tests/SkillServer.Integration.Tests/SkillServerClientNativeManifestTests.cs b/tests/SkillServer.Integration.Tests/SkillServerClientNativeManifestTests.cs index 9ea20f8..dc498ea 100644 --- a/tests/SkillServer.Integration.Tests/SkillServerClientNativeManifestTests.cs +++ b/tests/SkillServer.Integration.Tests/SkillServerClientNativeManifestTests.cs @@ -82,10 +82,12 @@ You are a client-tested sub-agent. var root = await _fixture.Client.GetManifestAsync(ct); Assert.NotNull(root); - Assert.Equal("/api/v1/manifest/skills/index.json", root.Links.Skills.Href); - Assert.Equal("/api/v1/manifest/subagents/index.json", root.Links.SubAgents.Href); + Assert.Equal("v1", root.ApiVersion); + var v1 = root.Versions["v1"]; + Assert.Equal("/skills/v1/index.json", v1.Skills.Href); + Assert.Equal("/subagents/v1/index.json", v1.SubAgents.Href); - var skillIndex = await _fixture.Client.GetNativeSkillIndexAsync(root.Links.Skills, ct); + var skillIndex = await _fixture.Client.GetNativeSkillIndexAsync(v1.Skills, ct); Assert.NotNull(skillIndex); var skillPageLink = Assert.Single(skillIndex.Pages); var skillPage = await _fixture.Client.GetNativeSkillPageAsync(skillPageLink, ct); @@ -103,7 +105,7 @@ You are a client-tested sub-agent. Assert.Equal(skillDetail.Artifact.Digest, skillDownload.Digest); Assert.Contains("# Client Native Manifest Test", Encoding.UTF8.GetString(skillDestination.ToArray())); - var subAgentIndex = await _fixture.Client.GetNativeSubAgentIndexAsync(root.Links.SubAgents, ct); + var subAgentIndex = await _fixture.Client.GetNativeSubAgentIndexAsync(v1.SubAgents, ct); Assert.NotNull(subAgentIndex); var subAgentPageLink = Assert.Single(subAgentIndex.Pages); var subAgentPage = await _fixture.Client.GetNativeSubAgentPageAsync(subAgentPageLink, ct); diff --git a/tests/SkillServer.Integration.Tests/SkillServerIntegrationTests.cs b/tests/SkillServer.Integration.Tests/SkillServerIntegrationTests.cs index dba21c1..760ee4a 100644 --- a/tests/SkillServer.Integration.Tests/SkillServerIntegrationTests.cs +++ b/tests/SkillServer.Integration.Tests/SkillServerIntegrationTests.cs @@ -164,15 +164,16 @@ public async Task NativeManifest_SkillTraversal_ReturnsRfcArtifactValues() var uploadResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/api/v1/skills", content, ct); Assert.Equal(HttpStatusCode.Created, uploadResponse.StatusCode); - var root = await _fixture.HttpClient.GetFromJsonAsync("/api/v1/manifest.json", ct); + var root = await _fixture.HttpClient.GetFromJsonAsync("/manifest.json", ct); Assert.NotNull(root); - Assert.Equal("/api/v1/manifest.json", root.Links.Self.Href); - Assert.Equal("/.well-known/agent-skills/index.json", root.Links.RfcSkills.Href); - Assert.Equal("/api/v1/manifest/skills/index.json", root.Links.Skills.Href); - Assert.Equal("/api/v1/manifest/subagents/index.json", root.Links.SubAgents.Href); + Assert.Equal("v1", root.ApiVersion); + var v1 = root.Versions["v1"]; + Assert.Equal("/manifest.json", v1.Self.Href); + Assert.Equal("/skills/v1/index.json", v1.Skills.Href); + Assert.Equal("/subagents/v1/index.json", v1.SubAgents.Href); var skillIndex = await _fixture.HttpClient.GetFromJsonAsync( - root.Links.Skills.Href, ct); + v1.Skills.Href, ct); Assert.NotNull(skillIndex); Assert.Equal("skill-index", skillIndex.Kind); var pageLink = Assert.Single(skillIndex.Pages); @@ -182,7 +183,7 @@ public async Task NativeManifest_SkillTraversal_ReturnsRfcArtifactValues() Assert.NotNull(skillPage); var item = Assert.Single(skillPage.Items, i => i.Name == skillName); Assert.Equal("1.0.0", item.LatestVersion); - Assert.Equal($"/api/v1/manifest/skills/{skillName}/index.json", item.Href); + Assert.Equal($"/skills/v1/{skillName}/index.json", item.Href); var identity = await _fixture.HttpClient.GetFromJsonAsync( item.Href, ct); @@ -196,7 +197,7 @@ public async Task NativeManifest_SkillTraversal_ReturnsRfcArtifactValues() Assert.Equal("skill-version", detail.Kind); Assert.NotNull(detail.RoutesToSubagent); Assert.Equal("technical-support-diagnostician", detail.RoutesToSubagent!.Name); - Assert.Equal("/api/v1/manifest/subagents/technical-support-diagnostician/index.json", detail.RoutesToSubagent.Href); + Assert.Equal("/subagents/v1/technical-support-diagnostician/index.json", detail.RoutesToSubagent.Href); var rfcIndex = await _fixture.Client.GetRfcIndexAsync(ct); Assert.NotNull(rfcIndex); @@ -295,12 +296,13 @@ You are a manifest-tested sub-agent. var uploadResponse = await _fixture.AuthenticatedHttpClient.PostAsync("/api/v1/subagents", content, ct); Assert.Equal(HttpStatusCode.Created, uploadResponse.StatusCode); - var root = await _fixture.HttpClient.GetFromJsonAsync("/api/v1/manifest.json", ct); + var root = await _fixture.HttpClient.GetFromJsonAsync("/manifest.json", ct); Assert.NotNull(root); - Assert.Equal("/api/v1/manifest/subagents/index.json", root.Links.SubAgents.Href); + var v1 = root.Versions["v1"]; + Assert.Equal("/subagents/v1/index.json", v1.SubAgents.Href); var subAgentIndex = await _fixture.HttpClient.GetFromJsonAsync( - root.Links.SubAgents.Href, ct); + v1.SubAgents.Href, ct); Assert.NotNull(subAgentIndex); Assert.Equal("subagent-index", subAgentIndex.Kind); var pageLink = Assert.Single(subAgentIndex.Pages); @@ -311,7 +313,7 @@ You are a manifest-tested sub-agent. Assert.Equal("subagent-page", subAgentPage.Kind); var item = Assert.Single(subAgentPage.Items, i => i.Name == subAgentName); Assert.Equal("1.0.0", item.LatestVersion); - Assert.Equal($"/api/v1/manifest/subagents/{subAgentName}/index.json", item.Href); + Assert.Equal($"/subagents/v1/{subAgentName}/index.json", item.Href); var identity = await _fixture.HttpClient.GetFromJsonAsync( item.Href, ct); @@ -1016,7 +1018,7 @@ Second section content. Assert.Equal(indexSkill.Digest, ComputeSha256Digest(archiveBytes)); var nativeDetail = await _fixture.HttpClient.GetFromJsonAsync( - $"/api/v1/manifest/skills/{skillName}/versions/1.0.0.json", ct); + $"/skills/v1/{skillName}/versions/1.0.0.json", ct); Assert.NotNull(nativeDetail); Assert.Equal(indexSkill.Type, nativeDetail.Artifact.Type); Assert.Equal(indexSkill.Url, nativeDetail.Artifact.Url); From 4b6c931835cdfaf4dbb8492f60f8c09859179d09 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 6 Jul 2026 18:12:16 +0000 Subject: [PATCH 3/3] Address PR review: extract MapV1Api, fix middleware ordering, add version negotiation test - Extract MapV1Api() extension method for cleaner API versioning - Move UseStaticFiles() after MapSkillServerEndpoints() to avoid unnecessary file system lookups - Extract CurrentApiVersion constant in NativeManifestGenerator - Add trailing newlines to SkillServerClient.cs and SubAgentClient.cs - Add test for ResolveVersionAsync when no compatible version exists All 215 tests pass. --- src/Netclaw.SkillClient/SkillServerClient.cs | 2 +- src/Netclaw.SkillClient/SubAgentClient.cs | 2 +- src/SkillServer/Endpoints.cs | 9 +++++-- src/SkillServer/Program.cs | 8 +++--- .../Services/NativeManifestGenerator.cs | 5 ++-- .../ClientForwardCompatTests.cs | 27 +++++++++++++++++++ 6 files changed, 43 insertions(+), 10 deletions(-) diff --git a/src/Netclaw.SkillClient/SkillServerClient.cs b/src/Netclaw.SkillClient/SkillServerClient.cs index 809aca4..4e3ba5e 100644 --- a/src/Netclaw.SkillClient/SkillServerClient.cs +++ b/src/Netclaw.SkillClient/SkillServerClient.cs @@ -327,4 +327,4 @@ public void Dispose() if (_ownsHttpClient) _httpClient.Dispose(); } -} \ No newline at end of file +} diff --git a/src/Netclaw.SkillClient/SubAgentClient.cs b/src/Netclaw.SkillClient/SubAgentClient.cs index aae5deb..662fedd 100644 --- a/src/Netclaw.SkillClient/SubAgentClient.cs +++ b/src/Netclaw.SkillClient/SubAgentClient.cs @@ -112,4 +112,4 @@ private async Task PostSubAgentAsync( return await _httpClient.PostAsync(Api("subagents"), content, ct); } -} \ No newline at end of file +} diff --git a/src/SkillServer/Endpoints.cs b/src/SkillServer/Endpoints.cs index 995086e..13838af 100644 --- a/src/SkillServer/Endpoints.cs +++ b/src/SkillServer/Endpoints.cs @@ -17,12 +17,17 @@ public static WebApplication MapSkillServerEndpoints(this WebApplication app) { app.MapDiscoveryEndpoints(); app.MapManifestEndpoints(); + app.MapV1Api(); + app.MapHealthEndpoints(); + return app; + } + + private static void MapV1Api(this WebApplication app) + { app.MapSkillEndpoints(); app.MapSubAgentEndpoints(); app.MapBlobEndpoints(); app.MapApiKeyEndpoints(); - app.MapHealthEndpoints(); - return app; } private static void MapDiscoveryEndpoints(this WebApplication app) diff --git a/src/SkillServer/Program.cs b/src/SkillServer/Program.cs index 92a5257..8231fa6 100644 --- a/src/SkillServer/Program.cs +++ b/src/SkillServer/Program.cs @@ -52,12 +52,12 @@ app.MapOpenApi(); } -// Serve static files for gallery UI -app.UseStaticFiles(); - -// Map API endpoints (must be before fallback) +// Map API endpoints (must be before fallback and static files) app.MapSkillServerEndpoints(); +// Serve static files for gallery UI (after API routes to avoid unnecessary file system lookups) +app.UseStaticFiles(); + // Serve gallery UI for non-API paths app.MapFallbackToFile("index.html"); diff --git a/src/SkillServer/Services/NativeManifestGenerator.cs b/src/SkillServer/Services/NativeManifestGenerator.cs index 8f232cc..037c504 100644 --- a/src/SkillServer/Services/NativeManifestGenerator.cs +++ b/src/SkillServer/Services/NativeManifestGenerator.cs @@ -10,6 +10,7 @@ namespace SkillServer.Services; public sealed class NativeManifestGenerator { + private const string CurrentApiVersion = "v1"; private const string SkillsPageRange = "all"; private const string SubAgentsPageRange = "all"; @@ -31,11 +32,11 @@ public Task GenerateRootAsync(CancellationToken ct = default { var manifest = new NativeRootManifest { - ApiVersion = "v1", + ApiVersion = CurrentApiVersion, Versions = new Dictionary { { - "v1", new NativeVersionLinks + CurrentApiVersion, new NativeVersionLinks { Self = Link("/manifest.json"), Skills = Link("/skills/v1/index.json"), diff --git a/tests/SkillServer.Integration.Tests/ClientForwardCompatTests.cs b/tests/SkillServer.Integration.Tests/ClientForwardCompatTests.cs index 7c6128d..d1674f5 100644 --- a/tests/SkillServer.Integration.Tests/ClientForwardCompatTests.cs +++ b/tests/SkillServer.Integration.Tests/ClientForwardCompatTests.cs @@ -137,4 +137,31 @@ public async Task GetNativeSkillVersion_WithUnknownArtifactType_IsTolerated() Assert.Equal("oci-image", detail.Artifact.Type); Assert.Null(detail.RoutesToSubagent); } + + [Fact] + public async Task ResolveVersionAsync_WithNoCompatibleVersion_ThrowsNotSupportedException() + { + const string json = """ + { + "$schema": "https://netclaw.dev/manifest/v1", + "apiVersion": "v2", + "versions": { + "v2": { + "self": { "href": "/manifest.json" }, + "skills": { "href": "/skills/v2/index.json" }, + "subagents": { "href": "/subagents/v2/index.json" }, + "skillSearch": { "href": "/api/v2/skills" }, + "subagentSearch": { "href": "/api/v2/subagents" } + } + } + } + """; + + using var client = CreateClient(json); + var ct = TestContext.Current.CancellationToken; + + var ex = await Assert.ThrowsAsync(() => client.ResolveVersionAsync(ct)); + Assert.Contains("v2", ex.Message); + Assert.Contains("v1", ex.Message); + } }