From 6f8dfcd9baea00304fa607a516935f267a4cb3b1 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 4 May 2026 23:39:22 +0000 Subject: [PATCH 1/6] Add skillserver CLI for skill publishing and management Introduce Netclaw.SkillServer.Cli, a .NET global tool for managing skills on a SkillServer instance. Supports publish, batch publish, delete, list, search, verify, config, and API key management. Key capabilities: - Publish skill directories (SKILL.md + resource files) with idempotent skip-on-conflict behavior and --force for re-publishing - Batch publish all skills in a directory via publish-all - Auth resolution: CLI flags > env vars > config file - AOT-compatible with hand-rolled YAML frontmatter parser - Distributable as dotnet global tool (PackAsTool) Also extends Netclaw.SkillClient with resource upload support (UploadSkillWithResourcesAsync) and API key management methods. --- SkillServer.slnx | 2 + src/Netclaw.SkillClient/Models.cs | 20 ++ src/Netclaw.SkillClient/SkillServerClient.cs | 53 ++++ src/Netclaw.SkillServer.Cli/CliArgsParser.cs | 171 ++++++++++++ .../Commands/ApiKeyCommand.cs | 156 +++++++++++ .../Commands/ConfigCommand.cs | 130 +++++++++ .../Commands/DeleteCommand.cs | 62 +++++ .../Commands/ListCommand.cs | 69 +++++ .../Commands/PublishAllCommand.cs | 95 +++++++ .../Commands/PublishCommand.cs | 93 +++++++ .../Commands/VerifyCommand.cs | 118 +++++++++ .../Commands/VersionsCommand.cs | 66 +++++ .../Config/CliConfig.cs | 21 ++ .../Config/ConfigResolver.cs | 65 +++++ .../Json/CliJsonContext.cs | 22 ++ .../Netclaw.SkillServer.Cli.csproj | 43 +++ .../Output/ConsoleOutput.cs | 77 ++++++ src/Netclaw.SkillServer.Cli/Program.cs | 126 +++++++++ .../Publishing/PublishOrchestrator.cs | 130 +++++++++ .../Publishing/SkillDirectoryScanner.cs | 123 +++++++++ .../CliArgsParserTests.cs | 200 ++++++++++++++ .../ConfigResolverTests.cs | 101 +++++++ .../Netclaw.SkillServer.Cli.Tests.csproj | 26 ++ .../SkillDirectoryScannerTests.cs | 248 ++++++++++++++++++ 24 files changed, 2217 insertions(+) create mode 100644 src/Netclaw.SkillServer.Cli/CliArgsParser.cs create mode 100644 src/Netclaw.SkillServer.Cli/Commands/ApiKeyCommand.cs create mode 100644 src/Netclaw.SkillServer.Cli/Commands/ConfigCommand.cs create mode 100644 src/Netclaw.SkillServer.Cli/Commands/DeleteCommand.cs create mode 100644 src/Netclaw.SkillServer.Cli/Commands/ListCommand.cs create mode 100644 src/Netclaw.SkillServer.Cli/Commands/PublishAllCommand.cs create mode 100644 src/Netclaw.SkillServer.Cli/Commands/PublishCommand.cs create mode 100644 src/Netclaw.SkillServer.Cli/Commands/VerifyCommand.cs create mode 100644 src/Netclaw.SkillServer.Cli/Commands/VersionsCommand.cs create mode 100644 src/Netclaw.SkillServer.Cli/Config/CliConfig.cs create mode 100644 src/Netclaw.SkillServer.Cli/Config/ConfigResolver.cs create mode 100644 src/Netclaw.SkillServer.Cli/Json/CliJsonContext.cs create mode 100644 src/Netclaw.SkillServer.Cli/Netclaw.SkillServer.Cli.csproj create mode 100644 src/Netclaw.SkillServer.Cli/Output/ConsoleOutput.cs create mode 100644 src/Netclaw.SkillServer.Cli/Program.cs create mode 100644 src/Netclaw.SkillServer.Cli/Publishing/PublishOrchestrator.cs create mode 100644 src/Netclaw.SkillServer.Cli/Publishing/SkillDirectoryScanner.cs create mode 100644 tests/Netclaw.SkillServer.Cli.Tests/CliArgsParserTests.cs create mode 100644 tests/Netclaw.SkillServer.Cli.Tests/ConfigResolverTests.cs create mode 100644 tests/Netclaw.SkillServer.Cli.Tests/Netclaw.SkillServer.Cli.Tests.csproj create mode 100644 tests/Netclaw.SkillServer.Cli.Tests/SkillDirectoryScannerTests.cs diff --git a/SkillServer.slnx b/SkillServer.slnx index ade5f1c..fc7f945 100644 --- a/SkillServer.slnx +++ b/SkillServer.slnx @@ -9,11 +9,13 @@ + + diff --git a/src/Netclaw.SkillClient/Models.cs b/src/Netclaw.SkillClient/Models.cs index c2e4a7b..9594674 100644 --- a/src/Netclaw.SkillClient/Models.cs +++ b/src/Netclaw.SkillClient/Models.cs @@ -199,6 +199,24 @@ public sealed record ApiKeySummary public DateTimeOffset? ExpiresAt { get; init; } } +public sealed record CreateApiKeyRequest +{ + [JsonPropertyName("label")] + public required string Label { get; init; } + + [JsonPropertyName("expiresAt")] + public DateTimeOffset? ExpiresAt { get; init; } +} + +public sealed record ErrorResponse +{ + [JsonPropertyName("error")] + public string Error { get; init; } = ""; + + [JsonPropertyName("message")] + public string Message { get; init; } = ""; +} + /// /// JSON serialization context for AOT support. /// @@ -212,5 +230,7 @@ public sealed record ApiKeySummary [JsonSerializable(typeof(IReadOnlyList))] [JsonSerializable(typeof(IReadOnlyList))] [JsonSerializable(typeof(IReadOnlyList))] +[JsonSerializable(typeof(CreateApiKeyRequest))] +[JsonSerializable(typeof(ErrorResponse))] [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] public partial class SkillServerClientJsonContext : JsonSerializerContext; diff --git a/src/Netclaw.SkillClient/SkillServerClient.cs b/src/Netclaw.SkillClient/SkillServerClient.cs index 750df1c..e08b5d9 100644 --- a/src/Netclaw.SkillClient/SkillServerClient.cs +++ b/src/Netclaw.SkillClient/SkillServerClient.cs @@ -194,6 +194,14 @@ public async Task> CheckUpdatesAsync( public async Task UploadSkillAsync( string name, string version, Stream skillMdContent, string? category = null, CancellationToken ct = default) + { + return await UploadSkillWithResourcesAsync(name, version, skillMdContent, [], category, ct); + } + + public async Task UploadSkillWithResourcesAsync( + string name, string version, Stream skillMdContent, + IReadOnlyList<(string RelativePath, Stream Content)> resources, + string? category = null, CancellationToken ct = default) { using var content = new MultipartFormDataContent(); content.Add(new StringContent(name), "name"); @@ -202,12 +210,33 @@ public async Task UploadSkillAsync( content.Add(new StringContent(category), "category"); content.Add(new StreamContent(skillMdContent), "file", "SKILL.md"); + foreach (var (relativePath, resourceStream) in resources) + content.Add(new StreamContent(resourceStream), "resources", relativePath); + var response = await _httpClient.PostAsync("skills", content, ct); response.EnsureSuccessStatusCode(); return (await response.Content.ReadFromJsonAsync( SkillServerClientJsonContext.Default.SkillUploadResponse, ct))!; } + public async Task TryUploadSkillWithResourcesAsync( + string name, string version, Stream skillMdContent, + IReadOnlyList<(string RelativePath, Stream Content)> resources, + string? category = null, CancellationToken ct = default) + { + using var content = new MultipartFormDataContent(); + content.Add(new StringContent(name), "name"); + content.Add(new StringContent(version), "version"); + if (category is not null) + content.Add(new StringContent(category), "category"); + content.Add(new StreamContent(skillMdContent), "file", "SKILL.md"); + + foreach (var (relativePath, resourceStream) in resources) + content.Add(new StreamContent(resourceStream), "resources", relativePath); + + return await _httpClient.PostAsync("skills", content, ct); + } + public async Task DeleteVersionAsync(string name, string version, CancellationToken ct = default) { var response = await _httpClient.DeleteAsync( @@ -215,6 +244,30 @@ public async Task DeleteVersionAsync(string name, string version, CancellationTo response.EnsureSuccessStatusCode(); } + 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", + request, SkillServerClientJsonContext.Default.CreateApiKeyRequest, ct); + response.EnsureSuccessStatusCode(); + return (await response.Content.ReadFromJsonAsync( + SkillServerClientJsonContext.Default.CreateApiKeyResponse, ct))!; + } + + public async Task> ListApiKeysAsync(CancellationToken ct = default) + { + var result = await _httpClient.GetFromJsonAsync("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); + response.EnsureSuccessStatusCode(); + } + public void Dispose() { if (_ownsHttpClient) diff --git a/src/Netclaw.SkillServer.Cli/CliArgsParser.cs b/src/Netclaw.SkillServer.Cli/CliArgsParser.cs new file mode 100644 index 0000000..49b29bf --- /dev/null +++ b/src/Netclaw.SkillServer.Cli/CliArgsParser.cs @@ -0,0 +1,171 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- + +namespace Netclaw.SkillServer.Cli; + +public sealed class ParsedArgs +{ + public string Command { get; init; } = ""; + public string SubCommand { get; init; } = ""; + public IReadOnlyList Positional { get; init; } = []; + public string? ServerUrl { get; init; } + public string? ApiKey { get; init; } + public string? OutputFormat { get; init; } + public bool Verbose { get; init; } + public bool Help { get; init; } + public bool Version { get; init; } + public bool Force { get; init; } + public bool DryRun { get; init; } + public bool Yes { get; init; } + public string? VersionOverride { get; init; } + public string? Search { get; init; } + public int? Skip { get; init; } + public int? Take { get; init; } + public string? Label { get; init; } + public string? ExpiresAt { get; init; } + public string? Key { get; init; } + public string? Value { get; init; } +} + +public static class CliArgsParser +{ + public static ParsedArgs Parse(string[] args) + { + var positional = new List(); + string? serverUrl = null, apiKey = null, outputFormat = null; + string? versionOverride = null, search = null, label = null, expiresAt = null; + string? configKey = null, configValue = null; + int? skip = null, take = null; + bool verbose = false, help = false, version = false, force = false, dryRun = false, yes = false; + + var command = ""; + var subCommand = ""; + var commandParsed = false; + + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + + if (!commandParsed && !arg.StartsWith('-')) + { + if (command == "") + { + command = arg.ToLowerInvariant(); + + // Two-word commands + if (command is "api-key" or "config" or "publish-all") + { + if (command is "api-key" or "config" && i + 1 < args.Length && !args[i + 1].StartsWith('-')) + { + subCommand = args[++i].ToLowerInvariant(); + } + + commandParsed = true; + } + else + { + commandParsed = true; + } + + continue; + } + } + + switch (arg) + { + case "--server-url" when i + 1 < args.Length: + serverUrl = args[++i]; + break; + case "--api-key" when i + 1 < args.Length: + apiKey = args[++i]; + break; + case "--output" when i + 1 < args.Length: + outputFormat = args[++i].ToLowerInvariant(); + break; + case "--version" when command == "": + version = true; + break; + case "--version" when i + 1 < args.Length: + versionOverride = args[++i]; + break; + case "--search" when i + 1 < args.Length: + search = args[++i]; + break; + case "--skip" when i + 1 < args.Length: + if (int.TryParse(args[++i], out var s)) skip = s; + break; + case "--take" when i + 1 < args.Length: + if (int.TryParse(args[++i], out var t)) take = t; + break; + case "--label" when i + 1 < args.Length: + label = args[++i]; + break; + case "--expires-at" when i + 1 < args.Length: + expiresAt = args[++i]; + break; + case "--verbose" or "-v": + verbose = true; + break; + case "--help" or "-h": + help = true; + break; + case "--force" or "-f": + force = true; + break; + case "--dry-run": + dryRun = true; + break; + case "--yes" or "-y": + yes = true; + break; + case "-V": + version = true; + break; + default: + if (!arg.StartsWith('-')) + { + // For config set, first two positionals are key/value + if (command == "config" && subCommand == "set") + { + if (configKey is null) + configKey = arg; + else + configValue ??= arg; + } + else + { + positional.Add(arg); + } + } + break; + } + } + + return new ParsedArgs + { + Command = command, + SubCommand = subCommand, + Positional = positional, + ServerUrl = serverUrl, + ApiKey = apiKey, + OutputFormat = outputFormat, + Verbose = verbose, + Help = help, + Version = version, + Force = force, + DryRun = dryRun, + Yes = yes, + VersionOverride = versionOverride, + Search = search, + Skip = skip, + Take = take, + Label = label, + ExpiresAt = expiresAt, + Key = configKey, + Value = configValue + }; + } +} diff --git a/src/Netclaw.SkillServer.Cli/Commands/ApiKeyCommand.cs b/src/Netclaw.SkillServer.Cli/Commands/ApiKeyCommand.cs new file mode 100644 index 0000000..0c108e8 --- /dev/null +++ b/src/Netclaw.SkillServer.Cli/Commands/ApiKeyCommand.cs @@ -0,0 +1,156 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- + +using System.Text.Json; +using Netclaw.SkillClient; +using Netclaw.SkillServer.Cli.Json; +using Netclaw.SkillServer.Cli.Output; + +namespace Netclaw.SkillServer.Cli.Commands; + +public static class ApiKeyCommand +{ + public static async Task ExecuteAsync(ParsedArgs args, SkillServerClient client) + { + if (args.Help) + { + PrintHelp(); + return 0; + } + + return args.SubCommand switch + { + "create" => await Create(args, client), + "list" => await List(args, client), + "delete" => await Delete(args, client), + _ => ShowSubcommandHelp() + }; + } + + private static async Task Create(ParsedArgs args, SkillServerClient client) + { + var label = args.Label; + if (string.IsNullOrWhiteSpace(label)) + { + ConsoleOutput.WriteError("Error: --label is required."); + return 1; + } + + DateTimeOffset? expiresAt = null; + if (!string.IsNullOrWhiteSpace(args.ExpiresAt)) + { + if (DateTimeOffset.TryParse(args.ExpiresAt, out var parsed)) + expiresAt = parsed; + else + { + ConsoleOutput.WriteError($"Error: Invalid date format for --expires-at: '{args.ExpiresAt}'"); + return 1; + } + } + + try + { + var response = await client.CreateApiKeyAsync(label, expiresAt); + ConsoleOutput.WriteSuccess($"Created API key: {response.Key}"); + ConsoleOutput.WriteInfo($"Label: {response.Label}"); + ConsoleOutput.WriteInfo($"ID: {response.Id}"); + return 0; + } + catch (HttpRequestException ex) + { + ConsoleOutput.WriteError($"Error: {ex.Message}"); + return 1; + } + } + + private static async Task List(ParsedArgs args, SkillServerClient client) + { + try + { + var keys = await client.ListApiKeysAsync(); + + if (args.OutputFormat == "json") + { + var json = JsonSerializer.Serialize(keys, + CliJsonContext.Default.IReadOnlyListApiKeySummary); + Console.WriteLine(json); + return 0; + } + + if (keys.Count == 0) + { + ConsoleOutput.WriteInfo("No API keys found."); + return 0; + } + + var headers = new[] { "ID", "LABEL", "CREATED", "EXPIRES" }; + var rows = keys.Select(k => new[] + { + k.Id.ToString(), + k.Label, + k.CreatedAt.ToString("yyyy-MM-dd"), + k.ExpiresAt?.ToString("yyyy-MM-dd") ?? "never" + }).ToList(); + + ConsoleOutput.WriteTable(headers, rows); + return 0; + } + catch (HttpRequestException ex) + { + ConsoleOutput.WriteError($"Error: {ex.Message}"); + return 1; + } + } + + private static async Task Delete(ParsedArgs args, SkillServerClient client) + { + if (args.Positional.Count == 0) + { + ConsoleOutput.WriteError("Usage: skillserver api-key delete "); + return 1; + } + + if (!long.TryParse(args.Positional[0], out var id)) + { + ConsoleOutput.WriteError($"Error: Invalid API key ID: '{args.Positional[0]}'"); + return 1; + } + + try + { + await client.DeleteApiKeyAsync(id); + ConsoleOutput.WriteSuccess($"Deleted API key {id}"); + return 0; + } + catch (HttpRequestException ex) + { + ConsoleOutput.WriteError($"Error: {ex.Message}"); + return 1; + } + } + + private static int ShowSubcommandHelp() + { + PrintHelp(); + return 1; + } + + private static void PrintHelp() + { + Console.WriteLine("Usage: skillserver api-key [options]"); + Console.WriteLine(); + Console.WriteLine("Manage server API keys."); + Console.WriteLine(); + Console.WriteLine("Subcommands:"); + Console.WriteLine(" create Create a new API key"); + Console.WriteLine(" list List all API keys"); + Console.WriteLine(" delete Delete an API key"); + Console.WriteLine(); + Console.WriteLine("Create options:"); + Console.WriteLine(" --label