diff --git a/feeds/skills/.system/files/netclaw-operations/SKILL.md b/feeds/skills/.system/files/netclaw-operations/SKILL.md index 9bb80f6a6..89fc281a8 100644 --- a/feeds/skills/.system/files/netclaw-operations/SKILL.md +++ b/feeds/skills/.system/files/netclaw-operations/SKILL.md @@ -3,7 +3,7 @@ name: netclaw-operations description: "REQUIRED when the user asks about scheduling, reminders, cron jobs, timers, background jobs, diagnostics, troubleshooting, MCP tools, daemon health, identity updates, or Netclaw capabilities and self-maintenance." metadata: author: netclaw - version: "2.74.4" + version: "2.76.0" --- # Netclaw Operations diff --git a/feeds/skills/.system/files/netclaw-operations/references/skills.md b/feeds/skills/.system/files/netclaw-operations/references/skills.md index 268130406..8d6e9d395 100644 --- a/feeds/skills/.system/files/netclaw-operations/references/skills.md +++ b/feeds/skills/.system/files/netclaw-operations/references/skills.md @@ -1,6 +1,3 @@ -# Skill Management - - ## Skill Management @@ -17,7 +14,7 @@ commands below only for explicit operator inspection and diagnostics. | Command | What it does | |---------|--------------| | `netclaw skill list` | List all discovered skills with source, version, status | -| `netclaw skill sync` | Run one external source sync pass and report each source result | +| `netclaw skill sync [--retry-rejected]` | Run one external source sync pass and report each source result | | `netclaw skill show ` | Show skill metadata and full content | | `netclaw skill validate ` | Validate a SKILL.md file's frontmatter format | | `netclaw skill remove ` | Remove a native skill (refuses system/external) | @@ -37,6 +34,38 @@ Register additional skill directories (e.g. `~/.claude/skills/`): | `netclaw skill source enable ` | Enable a disabled source | | `netclaw skill source disable ` | Disable without removing | +### Managed agent plugins + +The daemon can install Agent Plugin packages from a public GitHub repository. +Netclaw supports Agent Plugins 1.0.0 and the Codex compatibility format. +Netclaw imports skills only. +It does not activate hooks, MCP servers, agents, scripts, or other executable components. + +| Command | What it does | +|---------|--------------| +| `netclaw plugin install owner/repository` | Install from the repository's default branch | +| `netclaw plugin install owner/repository --branch main` | Track a named branch | +| `netclaw plugin install owner/repository --tag v1.2.0` | Resolve a tag once and pin its commit | +| `netclaw plugin install owner/repository --commit ` | Pin an exact commit | +| `netclaw plugin list [--json]` | Show each source and its installed package identity | +| `netclaw plugin enable ` | Enable and sync a source | +| `netclaw plugin disable ` | Disable a source | +| `netclaw plugin remove ` | Remove a source | + +Use `--id` to set the stable source ID. +Use `--format auto|agent-plugin|codex` to select the package contract. +The default `auto` format prefers a recognized Agent Plugins manifest. +Use `--subdirectory` when the plugin root is below the repository root. +Plugin mutations require confirmation unless the operator supplies `--yes`. +The install command waits for the daemon restart and its immediate sync. +A download failure leaves the valid source configured for a later sync. +A scanner rejection records the commit and keeps prior installed content active. +Use `netclaw skill sync --retry-rejected` to test known rejected commits again. +The daemon removes the rejection record after a successful explicit retry. + +The source ID controls later lifecycle commands. +The package manifest supplies a separate package name after a successful sync. + The daemon restores its system skills from its binary before its first scan. If this restore fails, confirm that Netclaw owns the skills directory and can write to its parent. If the error reports a reparse point, remove it from the diff --git a/scripts/smoke/run-smoke.sh b/scripts/smoke/run-smoke.sh index 2c079cdb2..a4e8168d7 100755 --- a/scripts/smoke/run-smoke.sh +++ b/scripts/smoke/run-smoke.sh @@ -67,6 +67,7 @@ LIGHT_SCENARIOS=( pairing mcp-setup skill-sync + plugin-management webhook-routes ) FULL_SCENARIOS=("${LIGHT_SCENARIOS[@]}") diff --git a/src/Netclaw.Cli.Tests/Skills/SkillCommandTests.cs b/src/Netclaw.Cli.Tests/Skills/SkillCommandTests.cs index bf3cd92fb..39ae3bf69 100644 --- a/src/Netclaw.Cli.Tests/Skills/SkillCommandTests.cs +++ b/src/Netclaw.Cli.Tests/Skills/SkillCommandTests.cs @@ -6,6 +6,7 @@ using System.Net; using System.Net.Http; using System.Text; +using System.Text.Json; using Microsoft.Extensions.Configuration; using Netclaw.Cli.Daemon; using Netclaw.Cli.Skills; @@ -25,6 +26,7 @@ namespace Netclaw.Cli.Tests.Skills; public sealed class SkillCommandTests : IDisposable { private const string UnavailableMarker = "Daemon unavailable"; + private const string PluginCommit = "13e26d39ed01d97ea592235d041304d289f4ba07"; private readonly DisposableTempDir _dir = new(); private readonly NetclawPaths _paths; @@ -52,10 +54,21 @@ private DaemonApi CreateDaemonApi(Func } private Task RunListAsync(DaemonApi? daemonApi) - => SkillCommand.RunAsync(["skill", "list"], _paths, daemonApi, output: _output); + => SkillCommand.RunAsync( + ["skill", "list"], _paths, TimeProvider.System, TextReader.Null, daemonApi, output: _output); private Task RunSyncAsync(DaemonApi? daemonApi) - => SkillCommand.RunAsync(["skill", "sync"], _paths, daemonApi, output: _output); + => SkillCommand.RunAsync( + ["skill", "sync"], _paths, TimeProvider.System, TextReader.Null, daemonApi, output: _output); + + private Task RunRetrySyncAsync(DaemonApi? daemonApi) + => SkillCommand.RunAsync( + ["skill", "sync", "--retry-rejected"], + _paths, + TimeProvider.System, + TextReader.Null, + daemonApi, + output: _output); // ── Success paths ───────────────────────────────────────────────── @@ -149,6 +162,525 @@ public async Task Sync_posts_to_the_daemon_and_reports_a_successful_pass() Assert.Contains("team: ok", _output.ToString()); } + [Fact] + public async Task Sync_retry_requests_an_explicit_rejected_commit_retry() + { + var daemonApi = CreateDaemonApi(request => + { + Assert.Equal("?retryRejected=true", request.RequestUri!.Query); + return FakeHttpMessageHandler.JsonResponse(new + { + passId = "pass-retry", + sources = Array.Empty(), + inventory = new { succeeded = true, acceptedCount = 0, rejectedCount = 0 }, + }); + }); + + var exit = await RunRetrySyncAsync(daemonApi); + + Assert.Equal(0, exit); + Assert.Contains("pass-retry", _output.ToString()); + } + + [Fact] + public async Task Plugin_install_waits_for_restart_and_immediate_sync() + { + var requests = new List(); + var daemonApi = CreateDaemonApi(request => + { + requests.Add($"{request.Method} {request.RequestUri!.PathAndQuery}"); + if (request.RequestUri.AbsolutePath == "/api/plugins" + && request.Method == HttpMethod.Post) + { + return FakeHttpMessageHandler.JsonResponse(new + { + restartGeneration = 4, + plugin = Plugin(ManagedPluginApi.PluginStatus.NotInstalled, installedCommit: null), + }); + } + if (request.RequestUri.AbsolutePath == "/api/health/ready") + { + var ready = new HttpResponseMessage(HttpStatusCode.OK); + ready.Headers.Add("X-Netclaw-Generation", "5"); + return ready; + } + if (request.RequestUri.AbsolutePath == "/api/skills/sync") + { + return FakeHttpMessageHandler.JsonResponse(new + { + passId = "install-pass", + sources = new[] + { + new + { + name = "fixture", sourceKind = SkillSyncResult.ServerFeedSourceKind, + changedCount = 0, unchangedCount = 0, rejectedCount = 0, failedCount = 1, + sidecar = "failed", + }, + new + { + name = "fixture", sourceKind = SkillSyncResult.GitPluginSourceKind, + changedCount = 1, unchangedCount = 0, rejectedCount = 0, failedCount = 0, + sidecar = "not-applicable", + }, + }, + inventory = new { succeeded = true, acceptedCount = 1, rejectedCount = 0 }, + }); + } + return FakeHttpMessageHandler.JsonResponse(new + { + plugins = new[] + { + Plugin(ManagedPluginApi.PluginStatus.Installed, "13e26d39ed01d97ea592235d041304d289f4ba07"), + }, + }); + }); + + var exit = await PluginCommand.RunAsync( + [ + "plugin", "install", "owner/repository", "--id", "fixture", + "--commit", "13e26d39ed01d97ea592235d041304d289f4ba07", + "--yes", + ], + daemonApi, + TimeProvider.System, + TextReader.Null, + _output); + + Assert.True(exit == 0, _output.ToString()); + Assert.Equal( + [ + "POST /api/plugins", + "GET /api/health/ready", + "POST /api/skills/sync", + "GET /api/plugins", + ], + requests); + Assert.Contains("Installed plugin 'fixture'", _output.ToString()); + } + + [Fact] + public async Task Plugin_remove_requires_confirmation_before_the_daemon_request() + { + var requested = false; + var daemonApi = CreateDaemonApi(_ => + { + requested = true; + return FakeHttpMessageHandler.JsonResponse(new { }); + }); + + using var input = new StringReader("no\n"); + var exit = await PluginCommand.RunAsync( + ["plugin", "remove", "fixture"], + daemonApi, + TimeProvider.System, + input, + _output); + + Assert.Equal(0, exit); + Assert.False(requested); + Assert.Contains("Cancelled.", _output.ToString()); + } + + [Fact] + public async Task Plugin_enable_syncs_an_enabled_source_that_is_not_installed() + { + var requests = new List(); + var listCount = 0; + var daemonApi = CreateDaemonApi(request => + { + requests.Add($"{request.Method} {request.RequestUri!.PathAndQuery}"); + if (request.Method == HttpMethod.Patch) + { + return FakeHttpMessageHandler.JsonResponse(new + { + restartGeneration = 4, + sourceId = "fixture", + changed = false, + }); + } + if (request.RequestUri.AbsolutePath == "/api/plugins") + { + var status = listCount++ == 0 + ? ManagedPluginApi.PluginStatus.NotInstalled + : ManagedPluginApi.PluginStatus.Installed; + return FakeHttpMessageHandler.JsonResponse(new + { + plugins = new[] { Plugin(status, status == ManagedPluginApi.PluginStatus.Installed + ? "13e26d39ed01d97ea592235d041304d289f4ba07" + : null) }, + }); + } + return FakeHttpMessageHandler.JsonResponse(new + { + passId = "enable-pass", + sources = new[] + { + new + { + name = "fixture", sourceKind = SkillSyncResult.GitPluginSourceKind, + changedCount = 1, unchangedCount = 0, rejectedCount = 0, failedCount = 0, + sidecar = "not-applicable", + }, + }, + inventory = new { succeeded = true, acceptedCount = 1, rejectedCount = 0 }, + }); + }); + + var exit = await PluginCommand.RunAsync( + ["plugin", "enable", "fixture", "--yes"], + daemonApi, + TimeProvider.System, + TextReader.Null, + _output); + + Assert.True(exit == 0, _output.ToString()); + Assert.Equal( + [ + "PATCH /api/plugins/fixture", + "GET /api/plugins", + "POST /api/skills/sync", + "GET /api/plugins", + ], + requests); + } + + [Fact] + public async Task Plugin_disable_waits_when_an_equal_disk_change_is_not_live() + { + var requests = new List(); + var listCount = 0; + var daemonApi = CreateDaemonApi(request => + { + requests.Add($"{request.Method} {request.RequestUri!.PathAndQuery}"); + if (request.Method == HttpMethod.Patch) + { + return FakeHttpMessageHandler.JsonResponse(new + { + restartGeneration = 4, + sourceId = "fixture", + changed = false, + }); + } + if (request.RequestUri.AbsolutePath == "/api/health/ready") + { + var ready = new HttpResponseMessage(HttpStatusCode.OK); + ready.Headers.Add("X-Netclaw-Generation", "5"); + return ready; + } + if (request.RequestUri.AbsolutePath == "/api/plugins") + { + var live = listCount++ > 0; + return FakeHttpMessageHandler.JsonResponse(new + { + plugins = new[] + { + live + ? Plugin(ManagedPluginApi.PluginStatus.Disabled, PluginCommit, enabled: false) + : Plugin(ManagedPluginApi.PluginStatus.Installed, PluginCommit, enabled: true), + }, + }); + } + return FakeHttpMessageHandler.JsonResponse(new + { + passId = "disable-pass", + sources = Array.Empty(), + inventory = new { succeeded = true, acceptedCount = 0, rejectedCount = 0 }, + }); + }); + + var exit = await PluginCommand.RunAsync( + ["plugin", "disable", "fixture", "--yes"], + daemonApi, + TimeProvider.System, + TextReader.Null, + _output); + + Assert.True(exit == 0, _output.ToString()); + Assert.Equal( + [ + "PATCH /api/plugins/fixture", + "GET /api/plugins", + "GET /api/health/ready", + "POST /api/skills/sync", + "GET /api/plugins", + ], + requests); + } + + [Fact] + public async Task Plugin_usage_errors_return_exit_code_two_without_a_daemon_request() + { + var requested = false; + var daemonApi = CreateDaemonApi(_ => + { + requested = true; + return FakeHttpMessageHandler.JsonResponse(new { }); + }); + + var missingArgument = await PluginCommand.RunAsync( + ["plugin", "install"], daemonApi, TimeProvider.System, TextReader.Null, _output); + var unknownOption = await PluginCommand.RunAsync( + ["plugin", "install", "owner/repository", "--unknown", "value"], + daemonApi, TimeProvider.System, TextReader.Null, _output); + var unknownAction = await PluginCommand.RunAsync( + ["plugin", "unknown"], daemonApi, TimeProvider.System, TextReader.Null, _output); + var unexpectedListArgument = await PluginCommand.RunAsync( + ["plugin", "list", "extra"], daemonApi, TimeProvider.System, TextReader.Null, _output); + + Assert.Equal(2, missingArgument); + Assert.Equal(2, unknownOption); + Assert.Equal(2, unknownAction); + Assert.Equal(2, unexpectedListArgument); + Assert.False(requested); + } + + [Fact] + public async Task Plugin_source_validation_error_returns_exit_code_one() + { + var daemonApi = CreateDaemonApi(_ => throw new InvalidOperationException("No request expected.")); + + var exit = await PluginCommand.RunAsync( + ["plugin", "install", "https://example.test/repository", "--yes"], + daemonApi, TimeProvider.System, TextReader.Null, _output); + + Assert.Equal(1, exit); + } + + [Fact] + public async Task Plugin_update_source_validation_error_returns_exit_code_one() + { + var daemonApi = CreateDaemonApi(_ => throw new InvalidOperationException("No request expected.")); + + var exit = await PluginCommand.RunAsync( + ["plugin", "update", "Invalid_ID", "--yes"], + daemonApi, + TimeProvider.System, + TextReader.Null, + _output); + + Assert.Equal(1, exit); + } + + [Fact] + public async Task Plugin_list_json_writes_one_stable_document() + { + var daemonApi = CreateDaemonApi(_ => FakeHttpMessageHandler.JsonResponse(new + { + plugins = new[] + { + Plugin(ManagedPluginApi.PluginStatus.Installed, PluginCommit), + }, + })); + + var exit = await PluginCommand.RunAsync( + ["plugin", "list", "--json"], + daemonApi, + TimeProvider.System, + TextReader.Null, + _output); + + Assert.Equal(0, exit); + using var document = JsonDocument.Parse(_output.ToString()); + var plugin = Assert.Single(document.RootElement.GetProperty("plugins").EnumerateArray()); + Assert.Equal("fixture", plugin.GetProperty("sourceId").GetString()); + Assert.Equal("fixture-package", plugin.GetProperty("manifestName").GetString()); + Assert.DoesNotContain("NAME", _output.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task Plugin_list_json_writes_an_empty_document_without_prose() + { + var daemonApi = CreateDaemonApi(_ => FakeHttpMessageHandler.JsonResponse(new + { + plugins = Array.Empty(), + })); + + var exit = await PluginCommand.RunAsync( + ["plugin", "list", "--json"], + daemonApi, + TimeProvider.System, + TextReader.Null, + _output); + + Assert.Equal(0, exit); + using var document = JsonDocument.Parse(_output.ToString()); + Assert.Empty(document.RootElement.GetProperty("plugins").EnumerateArray()); + Assert.DoesNotContain("No managed", _output.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task Plugin_list_text_shows_source_and_manifest_names() + { + var daemonApi = CreateDaemonApi(_ => FakeHttpMessageHandler.JsonResponse(new + { + plugins = new[] + { + Plugin(ManagedPluginApi.PluginStatus.Installed, PluginCommit), + }, + })); + + var exit = await PluginCommand.RunAsync( + ["plugin", "list"], + daemonApi, + TimeProvider.System, + TextReader.Null, + _output); + + Assert.Equal(0, exit); + Assert.Contains("SOURCE ID", _output.ToString()); + Assert.Contains("MANIFEST", _output.ToString()); + Assert.Contains("fixture-package", _output.ToString()); + } + + [Fact] + public async Task Plugin_command_reports_a_safe_daemon_problem_detail() + { + var daemonApi = CreateDaemonApi(_ => new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent( + "{\"title\":\"Invalid plugin\",\"detail\":\"The source ID is invalid.\\nRetry.\"}", + Encoding.UTF8, + "application/problem+json"), + }); + + var exit = await PluginCommand.RunAsync( + ["plugin", "list"], + daemonApi, + TimeProvider.System, + TextReader.Null, + _output); + + Assert.Equal(1, exit); + Assert.Contains("The source ID is invalid. Retry.", _output.ToString()); + Assert.DoesNotContain("HTTP 400", _output.ToString()); + } + + [Fact] + public async Task Plugin_command_replaces_an_oversized_error_body_with_a_status() + { + var remoteBody = new string('x', 5_000); + var daemonApi = CreateDaemonApi(_ => new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent(remoteBody, Encoding.UTF8, "application/problem+json"), + }); + + var exit = await PluginCommand.RunAsync( + ["plugin", "list"], + daemonApi, + TimeProvider.System, + TextReader.Null, + _output); + + Assert.Equal(1, exit); + Assert.Contains("HTTP 400", _output.ToString()); + Assert.DoesNotContain(new string('x', 100), _output.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task Plugin_update_uses_the_shared_sync_and_reports_one_source() + { + var requests = new List(); + var daemonApi = CreateDaemonApi(request => + { + requests.Add($"{request.Method} {request.RequestUri!.PathAndQuery}"); + if (request.Method == HttpMethod.Get) + { + return FakeHttpMessageHandler.JsonResponse(new + { + plugins = new[] + { + Plugin(ManagedPluginApi.PluginStatus.Installed, PluginCommit), + }, + }); + } + return FakeHttpMessageHandler.JsonResponse(new + { + passId = "update-pass", + sources = new[] + { + new + { + name = "fixture", + sourceKind = SkillSyncResult.GitPluginSourceKind, + changedCount = 1, + unchangedCount = 0, + rejectedCount = 0, + failedCount = 0, + sidecar = "not-applicable", + notices = new[] { "The importer ignored the unsupported MCP plugin component." }, + }, + }, + inventory = new { succeeded = true, acceptedCount = 1, rejectedCount = 0 }, + }); + }); + + var exit = await PluginCommand.RunAsync( + ["plugin", "update", "fixture", "--retry-rejected", "--yes"], + daemonApi, + TimeProvider.System, + TextReader.Null, + _output); + + Assert.Equal(0, exit); + Assert.Equal( + ["GET /api/plugins", "POST /api/skills/sync?retryRejected=true"], + requests); + Assert.Contains("fixture: changed=1", _output.ToString()); + Assert.Contains("unsupported MCP", _output.ToString()); + } + + [Fact] + public async Task Plugin_update_rejects_an_unknown_source_before_sync() + { + var requests = new List(); + var daemonApi = CreateDaemonApi(request => + { + requests.Add($"{request.Method} {request.RequestUri!.PathAndQuery}"); + return FakeHttpMessageHandler.JsonResponse(new { plugins = Array.Empty() }); + }); + + var exit = await PluginCommand.RunAsync( + ["plugin", "update", "missing", "--yes"], + daemonApi, + TimeProvider.System, + TextReader.Null, + _output); + + Assert.Equal(1, exit); + Assert.Equal(["GET /api/plugins"], requests); + Assert.Contains("does not exist", _output.ToString()); + } + + [Fact] + public async Task Plugin_help_lists_all_actions_without_a_daemon() + { + var exit = await PluginCommand.RunAsync( + ["plugin", "--help"], + daemonApi: null, + TimeProvider.System, + TextReader.Null, + _output); + + Assert.Equal(0, exit); + foreach (var action in new[] { "install", "list", "update", "enable", "disable", "remove" }) + Assert.Contains(action, _output.ToString()); + } + + [Fact] + public async Task Skill_plugin_is_not_a_nested_command() + { + var exit = await SkillCommand.RunAsync( + ["skill", "plugin", "list"], + _paths, + TimeProvider.System, + TextReader.Null, + daemonApi: null, + output: _output); + + Assert.Equal(2, exit); + } + [Fact] public async Task Sync_returns_nonzero_for_a_partial_source_failure() { @@ -334,9 +866,35 @@ public async Task List_reports_daemon_unavailable_when_the_request_fails_before_ [Fact] public async Task List_reports_daemon_unavailable_when_no_daemon_api_is_supplied() { - var exit = await SkillCommand.RunAsync(["skill", "list"], _paths, daemonApi: null, output: _output); + var exit = await SkillCommand.RunAsync( + ["skill", "list"], + _paths, + TimeProvider.System, + TextReader.Null, + daemonApi: null, + output: _output); Assert.Equal(1, exit); Assert.Contains(UnavailableMarker, _output.ToString()); } + + private static object Plugin( + ManagedPluginApi.PluginStatus status, + string? installedCommit, + bool enabled = true) => new + { + sourceId = "fixture", + manifestName = "fixture-package", + repository = "owner/repository", + sourceFormat = "codex", + manifestFormat = "codex", + subdirectory = (string?)null, + referenceKind = ManagedPluginReferenceKind.Commit, + reference = "13e26d39ed01d97ea592235d041304d289f4ba07", + enabled, + status, + installedCommit, + lastObservedCommit = installedCommit, + installedVersion = "1.0.0", + }; } diff --git a/src/Netclaw.Cli/CliArgsParser.cs b/src/Netclaw.Cli/CliArgsParser.cs index 882141ae8..33406b388 100644 --- a/src/Netclaw.Cli/CliArgsParser.cs +++ b/src/Netclaw.Cli/CliArgsParser.cs @@ -32,7 +32,7 @@ public static class CliArgsParser { "chat", "sessions", "init", "doctor", "status", "stats", "daemon", "mcp", "provider", "model", "reminder", "memory", - "secrets", "config", "update", "pair", "skill", "webhooks", + "secrets", "config", "update", "pair", "skill", "plugin", "webhooks", "approvals", }; diff --git a/src/Netclaw.Cli/Daemon/DaemonApi.cs b/src/Netclaw.Cli/Daemon/DaemonApi.cs index f505e8957..4730018ea 100644 --- a/src/Netclaw.Cli/Daemon/DaemonApi.cs +++ b/src/Netclaw.Cli/Daemon/DaemonApi.cs @@ -14,6 +14,10 @@ namespace Netclaw.Cli.Daemon; +internal sealed class DaemonProblemException( + HttpStatusCode statusCode, + string message) : HttpRequestException(message, null, statusCode); + /// /// Single shared abstraction for all daemon REST HTTP communication. /// Owns endpoint resolution, client creation, timeout, and deserialization. @@ -211,17 +215,152 @@ public async Task> ListSessionsAsync( /// A source pass can exceed normal status request limits. The caller controls /// only its wait through . /// - public async Task SyncSkillsAsync(CancellationToken ct = default) + public async Task SyncSkillsAsync( + CancellationToken ct = default, + bool retryRejected = false) { var client = CreateHttpClient(); client.Timeout = Timeout.InfiniteTimeSpan; - using var request = new HttpRequestMessage(HttpMethod.Post, $"{_endpoint}/api/skills/sync"); + var suffix = retryRejected ? "?retryRejected=true" : string.Empty; + using var request = new HttpRequestMessage( + HttpMethod.Post, + $"{_endpoint}/api/skills/sync{suffix}"); using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); response.EnsureSuccessStatusCode(); var stream = await response.Content.ReadAsStreamAsync(ct); return await JsonSerializer.DeserializeAsync(stream, JsonDefaults.Api, ct); } + public async Task ListPluginsAsync( + CancellationToken ct = default) + { + using var cts = CreateTimeoutCts(DefaultTimeout, ct); + var client = CreateHttpClient(); + using var response = await client.GetAsync($"{_endpoint}/api/plugins", cts.Token); + await EnsurePluginSuccessAsync(response, cts.Token); + var stream = await response.Content.ReadAsStreamAsync(cts.Token); + return await JsonSerializer.DeserializeAsync( + stream, + JsonDefaults.Api, + cts.Token); + } + + public async Task InstallPluginAsync( + ManagedPluginApi.InstallRequest request, + CancellationToken ct = default) + { + var client = CreateHttpClient(); + client.Timeout = Timeout.InfiniteTimeSpan; + using var response = await client.PostAsJsonAsync( + $"{_endpoint}/api/plugins", + request, + JsonDefaults.Api, + ct); + await EnsurePluginSuccessAsync(response, ct); + var stream = await response.Content.ReadAsStreamAsync(ct); + return await JsonSerializer.DeserializeAsync( + stream, + JsonDefaults.Api, + ct); + } + + public async Task SetPluginEnabledAsync( + string name, + bool enabled, + CancellationToken ct = default) + { + using var cts = CreateTimeoutCts(LongTimeout, ct); + var client = CreateHttpClient(); + using var request = new HttpRequestMessage( + HttpMethod.Patch, + $"{_endpoint}/api/plugins/{Uri.EscapeDataString(name)}") + { + Content = JsonContent.Create( + new ManagedPluginApi.SetEnabledRequest { Enabled = enabled }, + options: JsonDefaults.Api), + }; + using var response = await client.SendAsync(request, cts.Token); + await EnsurePluginSuccessAsync(response, cts.Token); + var stream = await response.Content.ReadAsStreamAsync(cts.Token); + return await JsonSerializer.DeserializeAsync( + stream, + JsonDefaults.Api, + cts.Token); + } + + public async Task RemovePluginAsync( + string name, + CancellationToken ct = default) + { + using var cts = CreateTimeoutCts(LongTimeout, ct); + var client = CreateHttpClient(); + using var response = await client.DeleteAsync( + $"{_endpoint}/api/plugins/{Uri.EscapeDataString(name)}", + cts.Token); + await EnsurePluginSuccessAsync(response, cts.Token); + var stream = await response.Content.ReadAsStreamAsync(cts.Token); + return await JsonSerializer.DeserializeAsync( + stream, + JsonDefaults.Api, + cts.Token); + } + + private static async Task EnsurePluginSuccessAsync( + HttpResponseMessage response, + CancellationToken cancellationToken) + { + if (response.IsSuccessStatusCode) + return; + + const int maximumProblemBytes = 4_096; + const int maximumMessageLength = 512; + var fallback = $"The daemon returned HTTP {(int)response.StatusCode}."; + string? message = null; + try + { + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + var buffer = new byte[maximumProblemBytes + 1]; + var length = 0; + while (length < buffer.Length) + { + var read = await stream.ReadAsync(buffer.AsMemory(length), cancellationToken); + if (read == 0) + break; + length += read; + } + if (length <= maximumProblemBytes) + { + using var document = JsonDocument.Parse(buffer.AsMemory(0, length)); + var root = document.RootElement; + if (root.ValueKind == JsonValueKind.Object) + { + message = ReadProblemText(root, "detail") ?? ReadProblemText(root, "title"); + } + } + } + catch (Exception ex) when (ex is IOException or JsonException) + { + message = null; + } + + var safe = message is null + ? fallback + : new string(message.Select(static character => char.IsControl(character) + || char.GetUnicodeCategory(character) == UnicodeCategory.Format ? ' ' : character).ToArray()) + .Trim(); + if (safe.Length == 0) + safe = fallback; + if (safe.Length > maximumMessageLength) + safe = safe[..maximumMessageLength]; + throw new DaemonProblemException(response.StatusCode, safe); + } + + private static string? ReadProblemText(JsonElement root, string propertyName) + => root.TryGetProperty(propertyName, out var value) + && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + // ── Reminders ───────────────────────────────────────────────────── public async Task ListRemindersAsync(CancellationToken ct = default) diff --git a/src/Netclaw.Cli/Daemon/DaemonRestartWaiter.cs b/src/Netclaw.Cli/Daemon/DaemonRestartWaiter.cs new file mode 100644 index 000000000..aee56d4e5 --- /dev/null +++ b/src/Netclaw.Cli/Daemon/DaemonRestartWaiter.cs @@ -0,0 +1,42 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- + +namespace Netclaw.Cli.Daemon; + +/// Waits until a config change produces a healthy daemon generation. +internal sealed class DaemonRestartWaiter(DaemonApi daemonApi, TimeProvider timeProvider) +{ + internal static readonly TimeSpan ReadyTimeout = TimeSpan.FromSeconds(90); + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(1); + + public async Task WaitAsync( + int priorGeneration, + CancellationToken cancellationToken) + { + var deadline = timeProvider.GetUtcNow() + ReadyTimeout; + while (timeProvider.GetUtcNow() < deadline) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var readiness = await daemonApi.ProbeReadinessAsync(cancellationToken); + if (readiness.Healthy && readiness.Generation > priorGeneration) + return true; + } + catch (Exception ex) when ( + (ex is HttpRequestException or OperationCanceledException) + && !cancellationToken.IsCancellationRequested) + { + await Task.Delay(PollInterval, timeProvider, cancellationToken); + continue; + } + + await Task.Delay(PollInterval, timeProvider, cancellationToken); + } + + return false; + } +} diff --git a/src/Netclaw.Cli/Program.cs b/src/Netclaw.Cli/Program.cs index 481fcad7c..9c0b9677b 100644 --- a/src/Netclaw.Cli/Program.cs +++ b/src/Netclaw.Cli/Program.cs @@ -846,6 +846,29 @@ static async Task RunAsync(string[] args) return; } + // ── Plugin management ── + if (mode is "plugin") + { + try + { + var builder = CreateQuietHostBuilder(args); + using var pluginHost = builder.Build(); + Environment.ExitCode = await PluginCommand.RunAsync( + args, + pluginHost.Services.GetRequiredService(), + pluginHost.Services.GetRequiredService(), + Console.In, + Console.Out); + } + catch (Exception ex) when (ex is InvalidDataException or InvalidOperationException or FormatException) + { + Console.Error.WriteLine($"plugin: could not load local configuration: {ex.Message}"); + Console.Error.WriteLine("Fix the file it names under ~/.netclaw/config, and retry."); + Environment.ExitCode = 1; + } + return; + } + // ── Skill management ── if (mode is "skill") { @@ -866,7 +889,12 @@ static async Task RunAsync(string[] args) var skillPaths = skillHost.Services.GetRequiredService(); skillPaths.EnsureDirectoriesExist(); var skillDaemonApi = skillHost.Services.GetRequiredService(); - Environment.ExitCode = await SkillCommand.RunAsync(args, skillPaths, skillDaemonApi); + Environment.ExitCode = await SkillCommand.RunAsync( + args, + skillPaths, + skillHost.Services.GetRequiredService(), + Console.In, + skillDaemonApi); } catch (Exception ex) when (ex is InvalidDataException or InvalidOperationException or FormatException) { @@ -881,7 +909,7 @@ static async Task RunAsync(string[] args) // All other skill subcommands are offline filesystem operations — no daemon needed. var paths = new NetclawPaths(); paths.EnsureDirectoriesExist(); - Environment.ExitCode = await SkillCommand.RunAsync(args, paths); + Environment.ExitCode = await SkillCommand.RunAsync(args, paths, TimeProvider.System, Console.In); return; } @@ -1316,6 +1344,7 @@ static void WriteGeneralHelp() Console.WriteLine(" reminder Manage scheduled reminders (daemon-required)"); Console.WriteLine(" memory Manage cross-session memory (embeddings backfill, offline)"); Console.WriteLine(" skill Manage skills and skill sources"); + Console.WriteLine(" plugin Manage Git-based agent plugins"); Console.WriteLine(" webhooks Manage inbound webhook routes"); Console.WriteLine(" secrets Manage encrypted secrets (set key/value pairs)"); Console.WriteLine(" init First-run setup wizard"); diff --git a/src/Netclaw.Cli/Skills/PluginCommand.cs b/src/Netclaw.Cli/Skills/PluginCommand.cs new file mode 100644 index 000000000..7c32968cb --- /dev/null +++ b/src/Netclaw.Cli/Skills/PluginCommand.cs @@ -0,0 +1,675 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Globalization; +using System.Text.Json; +using Netclaw.Cli.Daemon; +using Netclaw.Cli.Json; +using Netclaw.Configuration; + +namespace Netclaw.Cli.Skills; + +internal static class PluginCommand +{ + public static async Task RunAsync( + string[] args, + DaemonApi? daemonApi, + TimeProvider timeProvider, + TextReader input, + TextWriter output) + { + var action = args.Length > 1 ? args[1] : "help"; + if (action is "help" or "-h" or "--help") + return WriteHelp(output); + if (daemonApi is null) + { + output.WriteLine("Daemon unavailable: the daemon API is not configured."); + return 1; + } + + using var cancellation = new CancellationTokenSource(); + ConsoleCancelEventHandler cancelHandler = (_, eventArgs) => + { + eventArgs.Cancel = true; + cancellation.Cancel(); + }; + Console.CancelKeyPress += cancelHandler; + try + { + return action switch + { + "install" => await InstallAsync(args, daemonApi, timeProvider, input, output, cancellation.Token), + "list" => args.Length is 2 or 3 && (args.Length == 2 || args[2] == "--json") + ? await ListAsync(daemonApi, args.Length == 3, output, cancellation.Token) + : WriteListUsage(output), + "update" => await UpdateAsync(args, daemonApi, input, output, cancellation.Token), + "enable" => await SetEnabledAsync(args, true, daemonApi, timeProvider, input, output, cancellation.Token), + "disable" => await SetEnabledAsync(args, false, daemonApi, timeProvider, input, output, cancellation.Token), + "remove" => await RemoveAsync(args, daemonApi, timeProvider, input, output, cancellation.Token), + _ => WriteUnknownAction(action, output), + }; + } + catch (DaemonProblemException ex) + { + output.WriteLine($"Plugin command failed: {ex.Message}"); + return 1; + } + catch (HttpRequestException ex) + { + output.WriteLine(ex.StatusCode is null + ? $"Plugin command failed: could not reach the daemon ({ex.Message})." + : $"Plugin command failed: the daemon returned HTTP {(int)ex.StatusCode}."); + return 1; + } + catch (OperationCanceledException) + { + output.WriteLine("Plugin command canceled."); + return 1; + } + catch (Exception ex) + { + output.WriteLine($"Plugin command failed: {ex.Message}"); + return 1; + } + finally + { + Console.CancelKeyPress -= cancelHandler; + } + } + + private static async Task InstallAsync( + string[] args, + DaemonApi api, + TimeProvider timeProvider, + TextReader input, + TextWriter output, + CancellationToken cancellationToken) + { + if (!TryParseInstall(args, output, out var request, out var confirmed, out var errorExitCode)) + return errorExitCode; + if (!await ConfirmAsync( + confirmed, + $"Configure plugin from '{request.Repository}'? [y/N]: ", + input, + output, + cancellationToken)) + return 0; + + var response = await api.InstallPluginAsync(request, cancellationToken); + if (response?.Plugin is null || string.IsNullOrWhiteSpace(response.Plugin.SourceId)) + { + output.WriteLine("Plugin install failed: the daemon returned an unreadable result."); + return 1; + } + + output.WriteLine($"Configured plugin '{response.Plugin.SourceId}'."); + + return await ApplyAndVerifyAsync( + api, + timeProvider, + response.RestartGeneration, + response.Plugin.SourceId, + ManagedPluginApi.PluginStatus.Installed, + output, + cancellationToken); + } + + private static async Task ListAsync( + DaemonApi api, + bool json, + TextWriter output, + CancellationToken cancellationToken) + { + var response = await api.ListPluginsAsync(cancellationToken); + if (response?.Plugins is null) + { + output.WriteLine("Plugin list failed: the daemon returned an unreadable result."); + return 1; + } + if (json) + { + output.WriteLine(JsonSerializer.Serialize(response, JsonDefaults.Api)); + return 0; + } + if (response.Plugins.Count == 0) + { + output.WriteLine("No managed skill plugins."); + return 0; + } + + output.WriteLine($"{"SOURCE ID",-24} {"MANIFEST",-24} {"STATUS",-14} {"VERSION",-14} REFERENCE"); + foreach (var plugin in response.Plugins) + { + var version = SafeText(plugin.InstalledVersion ?? "-", 64); + var manifestName = SafeText(plugin.ManifestName ?? "-", 64); + var reference = plugin.ReferenceKind == ManagedPluginReferenceKind.Commit + ? plugin.Reference[..Math.Min(12, plugin.Reference.Length)] + : plugin.Reference; + output.WriteLine( + $"{plugin.SourceId,-24} {manifestName,-24} {StatusText(plugin.Status),-14} {version,-14} {plugin.ReferenceKind}:{reference}"); + } + return 0; + } + + private static async Task SetEnabledAsync( + string[] args, + bool enabled, + DaemonApi api, + TimeProvider timeProvider, + TextReader input, + TextWriter output, + CancellationToken cancellationToken) + { + if (!TryReadName( + args, enabled ? "enable" : "disable", output, out var name, out var confirmed, out var errorExitCode)) + { + return errorExitCode; + } + if (!await ConfirmAsync( + confirmed, + $"{(enabled ? "Enable" : "Disable")} plugin '{name}'? [y/N]: ", + input, + output, + cancellationToken)) + return 0; + + var response = await api.SetPluginEnabledAsync(name, enabled, cancellationToken); + if (response is null) + { + output.WriteLine("Plugin change failed: the daemon returned an unreadable result."); + return 1; + } + if (!response.Changed) + { + var plugins = await api.ListPluginsAsync(cancellationToken); + var plugin = plugins?.Plugins.FirstOrDefault( + item => string.Equals(item.SourceId, name, StringComparison.OrdinalIgnoreCase)); + if (plugin is null) + { + output.WriteLine($"Plugin '{name}' state could not be verified."); + return 1; + } + + if (plugin.Enabled != enabled) + { + if (!await WaitForRestartAsync( + api, + timeProvider, + response.RestartGeneration, + output, + cancellationToken)) + { + return 1; + } + } + else if (!enabled || plugin.Status == ManagedPluginApi.PluginStatus.Installed) + { + output.WriteLine(enabled + ? $"Plugin '{name}' is already enabled." + : $"Plugin '{name}' is already disabled."); + return 0; + } + + return await SyncAndVerifyAsync( + api, + name, + enabled ? ManagedPluginApi.PluginStatus.Installed : ManagedPluginApi.PluginStatus.Disabled, + output, + cancellationToken); + } + + return await ApplyAndVerifyAsync( + api, + timeProvider, + response.RestartGeneration, + name, + enabled ? ManagedPluginApi.PluginStatus.Installed : ManagedPluginApi.PluginStatus.Disabled, + output, + cancellationToken); + } + + private static async Task UpdateAsync( + string[] args, + DaemonApi api, + TextReader input, + TextWriter output, + CancellationToken cancellationToken) + { + if (!TryParseUpdate( + args, + output, + out var sourceId, + out var updateAll, + out var retryRejected, + out var confirmed, + out var errorExitCode)) + { + return errorExitCode; + } + var target = updateAll ? "all plugins" : $"plugin '{sourceId}'"; + if (!await ConfirmAsync( + confirmed, + $"Update {target}? [y/N]: ", + input, + output, + cancellationToken)) + { + return 0; + } + + var list = await api.ListPluginsAsync(cancellationToken); + if (list?.Plugins is null) + { + output.WriteLine("Plugin update failed: the daemon returned an unreadable plugin list."); + return 1; + } + var targets = updateAll + ? list.Plugins + : list.Plugins.Where( + plugin => string.Equals(plugin.SourceId, sourceId, StringComparison.OrdinalIgnoreCase)).ToList(); + if (targets.Count == 0) + { + output.WriteLine(updateAll + ? "No managed plugins." + : $"Plugin source '{sourceId}' does not exist."); + return updateAll ? 0 : 1; + } + + var sync = await api.SyncSkillsAsync(cancellationToken, retryRejected); + if (sync?.Sources is null || sync.Inventory.Succeeded != true) + { + output.WriteLine("Plugin update failed: the shared skill sync did not complete."); + return 1; + } + + var failed = false; + foreach (var plugin in targets) + { + var result = sync.Sources.FirstOrDefault( + source => source.SourceKind == SkillSyncResult.GitPluginSourceKind + && string.Equals(source.Name, plugin.SourceId, StringComparison.OrdinalIgnoreCase)); + if (result is null) + { + output.WriteLine(plugin.Enabled + ? $"{plugin.SourceId}: no sync result" + : $"{plugin.SourceId}: disabled"); + failed |= plugin.Enabled; + continue; + } + + output.WriteLine( + $"{plugin.SourceId}: changed={result.ChangedCount} unchanged={result.UnchangedCount} rejected={result.RejectedCount} failed={result.FailedCount}"); + WriteNotices(result, output); + failed |= result.RejectedCount > 0 || result.FailedCount > 0; + } + return failed ? 1 : 0; + } + + private static async Task RemoveAsync( + string[] args, + DaemonApi api, + TimeProvider timeProvider, + TextReader input, + TextWriter output, + CancellationToken cancellationToken) + { + if (!TryReadName(args, "remove", output, out var name, out var confirmed, out var errorExitCode)) + return errorExitCode; + if (!await ConfirmAsync( + confirmed, + $"Remove plugin '{name}'? [y/N]: ", + input, + output, + cancellationToken)) + return 0; + + var response = await api.RemovePluginAsync(name, cancellationToken); + if (response is null) + { + output.WriteLine("Plugin removal failed: the daemon returned an unreadable result."); + return 1; + } + if (!await WaitForRestartAsync(api, timeProvider, response.RestartGeneration, output, cancellationToken)) + return 1; + + var sync = await api.SyncSkillsAsync(cancellationToken); + if (sync?.Inventory.Succeeded != true) + { + output.WriteLine($"Plugin '{name}' was removed, but the skill inventory refresh failed."); + return 1; + } + + var plugins = await api.ListPluginsAsync(cancellationToken); + if (plugins?.Plugins.Any(item => string.Equals(item.SourceId, name, StringComparison.OrdinalIgnoreCase)) != false) + { + output.WriteLine($"Plugin '{name}' removal could not be verified."); + return 1; + } + + output.WriteLine($"Removed plugin '{name}'."); + return 0; + } + + private static async Task ApplyAndVerifyAsync( + DaemonApi api, + TimeProvider timeProvider, + int generation, + string name, + ManagedPluginApi.PluginStatus expectedStatus, + TextWriter output, + CancellationToken cancellationToken) + { + if (!await WaitForRestartAsync(api, timeProvider, generation, output, cancellationToken)) + return 1; + + return await SyncAndVerifyAsync( + api, + name, + expectedStatus, + output, + cancellationToken); + } + + private static async Task SyncAndVerifyAsync( + DaemonApi api, + string name, + ManagedPluginApi.PluginStatus expectedStatus, + TextWriter output, + CancellationToken cancellationToken) + { + var sync = await api.SyncSkillsAsync(cancellationToken); + var source = sync?.Sources.FirstOrDefault( + item => item.SourceKind == SkillSyncResult.GitPluginSourceKind + && string.Equals(item.Name, name, StringComparison.OrdinalIgnoreCase)); + var plugins = await api.ListPluginsAsync(cancellationToken); + var plugin = plugins?.Plugins.FirstOrDefault( + item => string.Equals(item.SourceId, name, StringComparison.OrdinalIgnoreCase)); + if (sync?.Inventory.Succeeded != true + || plugin?.Status != expectedStatus + || (expectedStatus == ManagedPluginApi.PluginStatus.Installed + && (source is null || source.FailedCount > 0 || source.RejectedCount > 0))) + { + output.WriteLine($"Plugin '{name}' is configured but is not installed."); + return 1; + } + + if (source is not null) + WriteNotices(source, output); + + output.WriteLine(expectedStatus == ManagedPluginApi.PluginStatus.Disabled + ? $"Disabled plugin '{name}'." + : $"Installed plugin '{name}' at commit {plugin.InstalledCommit}."); + return 0; + } + + private static async Task WaitForRestartAsync( + DaemonApi api, + TimeProvider timeProvider, + int generation, + TextWriter output, + CancellationToken cancellationToken) + { + output.WriteLine("Waiting for the daemon to apply the plugin configuration."); + var ready = await new DaemonRestartWaiter(api, timeProvider) + .WaitAsync(generation, cancellationToken); + if (ready) + return true; + + output.WriteLine("The plugin configuration was saved, but the daemon did not become ready."); + return false; + } + + private static bool TryParseInstall( + string[] args, + TextWriter output, + out ManagedPluginApi.InstallRequest request, + out bool confirmed, + out int errorExitCode) + { + request = null!; + confirmed = false; + errorExitCode = 2; + if (args.Length < 3) + { + output.WriteLine("Usage: netclaw plugin install [options]"); + return false; + } + + var repository = args[2]; + string? sourceId = null; + string? format = null; + string? subdirectory = null; + string? reference = null; + var referenceKind = ManagedPluginApi.InstallReferenceKind.DefaultBranch; + var referenceOptionSeen = false; + var timeoutSeconds = 60; + + for (var index = 3; index < args.Length; index++) + { + var option = args[index]; + if (option is "--yes" or "-y") + { + confirmed = true; + continue; + } + if (index + 1 >= args.Length) + { + output.WriteLine($"Option '{option}' requires a value."); + return false; + } + var value = args[++index]; + switch (option) + { + case "--id": sourceId = value; break; + case "--format": format = value; break; + case "--subdirectory": subdirectory = value; break; + case "--timeout-seconds" when int.TryParse(value, out timeoutSeconds): break; + case "--timeout-seconds": + output.WriteLine("The plugin timeout must be an integer."); + return false; + case "--branch": + case "--tag": + case "--commit": + if (referenceOptionSeen) + { + output.WriteLine("Use only one of --branch, --tag, or --commit."); + return false; + } + referenceOptionSeen = true; + reference = value; + referenceKind = option switch + { + "--branch" => ManagedPluginApi.InstallReferenceKind.Branch, + "--tag" => ManagedPluginApi.InstallReferenceKind.Tag, + _ => ManagedPluginApi.InstallReferenceKind.Commit, + }; + break; + default: + output.WriteLine($"Unknown plugin install option '{option}'."); + return false; + } + } + + if (!ManagedPluginSourceValidator.TryNormalizeRepository(repository, out _, out var repositoryError)) + { + output.WriteLine(repositoryError); + errorExitCode = 1; + return false; + } + if (sourceId is not null && !ManagedPluginSourceValidator.TryValidateId(sourceId, out var idError)) + { + output.WriteLine(idError); + errorExitCode = 1; + return false; + } + if (!ManagedPluginSourceValidator.TryNormalizeRelativePath( + subdirectory, + allowEmpty: true, + out _, + out var pathError)) + { + output.WriteLine(pathError); + errorExitCode = 1; + return false; + } + if (timeoutSeconds is < 1 or > 300) + { + output.WriteLine("The plugin timeout must be from 1 through 300 seconds."); + errorExitCode = 1; + return false; + } + + request = new ManagedPluginApi.InstallRequest + { + Repository = repository, + SourceId = sourceId, + Format = format ?? ManagedPluginSourceValidator.AutoFormat, + Subdirectory = subdirectory, + ReferenceKind = referenceKind, + Reference = reference, + TimeoutSeconds = timeoutSeconds, + }; + return true; + } + + private static bool TryReadName( + string[] args, + string action, + TextWriter output, + out string name, + out bool confirmed, + out int errorExitCode) + { + name = args.Length > 2 ? args[2] : string.Empty; + confirmed = args.Length == 4 && args[3] is "--yes" or "-y"; + errorExitCode = 2; + if (args.Length != 3 && !confirmed) + { + output.WriteLine($"Usage: netclaw plugin {action} [--yes]"); + return false; + } + if (!ManagedPluginSourceValidator.TryValidateId(name, out var error)) + { + output.WriteLine(error); + errorExitCode = 1; + return false; + } + return true; + } + + private static bool TryParseUpdate( + string[] args, + TextWriter output, + out string? sourceId, + out bool updateAll, + out bool retryRejected, + out bool confirmed, + out int errorExitCode) + { + sourceId = null; + updateAll = false; + retryRejected = false; + confirmed = false; + errorExitCode = 2; + foreach (var value in args.Skip(2)) + { + switch (value) + { + case "--all": updateAll = true; break; + case "--retry-rejected": retryRejected = true; break; + case "--yes" or "-y": confirmed = true; break; + case string when !value.StartsWith("-", StringComparison.Ordinal) && sourceId is null: + sourceId = value; + break; + default: + output.WriteLine($"Unknown plugin update option '{value}'."); + return false; + } + } + if (updateAll == (sourceId is not null)) + { + output.WriteLine("Usage: netclaw plugin update |--all [--retry-rejected] [--yes]"); + return false; + } + if (sourceId is not null && !ManagedPluginSourceValidator.TryValidateId(sourceId, out var error)) + { + output.WriteLine(error); + errorExitCode = 1; + return false; + } + return true; + } + + private static async Task ConfirmAsync( + bool confirmed, + string prompt, + TextReader input, + TextWriter output, + CancellationToken cancellationToken) + { + if (confirmed) + return true; + + output.Write(prompt); + var response = (await input.ReadLineAsync(cancellationToken))?.Trim(); + if (response is "y" or "Y" or "yes" or "Yes" or "YES") + return true; + + output.WriteLine("Cancelled."); + return false; + } + + private static string StatusText(ManagedPluginApi.PluginStatus status) => status switch + { + ManagedPluginApi.PluginStatus.NotInstalled => "not-installed", + _ => status.ToString().ToLowerInvariant(), + }; + + private static void WriteNotices(SkillSyncResult.SourceRow source, TextWriter output) + { + foreach (var notice in source.Notices) + output.WriteLine($"Notice: {SafeText(notice, 512)}"); + } + + private static string SafeText(string value, int maximumLength) + { + var safe = new string(value.Select( + static character => char.IsControl(character) + || char.GetUnicodeCategory(character) == UnicodeCategory.Format ? ' ' : character).ToArray()).Trim(); + return safe.Length <= maximumLength ? safe : safe[..maximumLength]; + } + + private static int WriteHelp(TextWriter output) + { + output.WriteLine("Usage: netclaw plugin "); + output.WriteLine(); + output.WriteLine("Actions:"); + output.WriteLine(" install [options] Validate and install a public GitHub plugin"); + output.WriteLine(" list [--json] List managed plugins"); + output.WriteLine(" update |--all Run the shared plugin sync"); + output.WriteLine(" enable Enable a plugin"); + output.WriteLine(" disable Disable a plugin"); + output.WriteLine(" remove Remove a plugin"); + output.WriteLine(); + output.WriteLine("Install options: --id, --format, --subdirectory, --branch, --tag,"); + output.WriteLine(" --commit, --timeout-seconds, --yes"); + output.WriteLine("Mutation options: --yes skips the confirmation prompt."); + output.WriteLine("All plugin actions need the running daemon."); + return 0; + } + + private static int WriteUnknownAction(string action, TextWriter output) + { + output.WriteLine($"Unknown plugin action '{action}'."); + WriteHelp(output); + return 2; + } + + private static int WriteListUsage(TextWriter output) + { + output.WriteLine("Usage: netclaw plugin list [--json]"); + return 2; + } +} diff --git a/src/Netclaw.Cli/Skills/SkillCommand.cs b/src/Netclaw.Cli/Skills/SkillCommand.cs index b284ecba1..bb89f174c 100644 --- a/src/Netclaw.Cli/Skills/SkillCommand.cs +++ b/src/Netclaw.Cli/Skills/SkillCommand.cs @@ -24,7 +24,12 @@ namespace Netclaw.Cli.Skills; internal static class SkillCommand { public static Task RunAsync( - string[] args, NetclawPaths paths, DaemonApi? daemonApi = null, TextWriter? output = null) + string[] args, + NetclawPaths paths, + TimeProvider timeProvider, + TextReader input, + DaemonApi? daemonApi = null, + TextWriter? output = null) { var subcommand = args.Length > 1 ? args[1] : "list"; @@ -56,7 +61,13 @@ public static Task RunAsync( return RunListAsync(daemonApi, output ?? Console.Out); if (subcommand is "sync") - return RunSyncAsync(daemonApi, output ?? Console.Out); + return RunSyncAsync(args, daemonApi, output ?? Console.Out); + + if (subcommand is "plugin") + { + WriteHelp(); + return Task.FromResult(2); + } return Task.FromResult(subcommand switch { @@ -165,8 +176,17 @@ private static int RenderInventory(IReadOnlyList skills return 0; } - private static async Task RunSyncAsync(DaemonApi? daemonApi, TextWriter output) + private static async Task RunSyncAsync( + string[] args, + DaemonApi? daemonApi, + TextWriter output) { + var retryRejected = args.Length == 3 && args[2] == "--retry-rejected"; + if (args.Length > 2 && !retryRejected) + { + output.WriteLine("Usage: netclaw skill sync [--retry-rejected]"); + return 1; + } if (daemonApi is null) { output.WriteLine("Daemon unavailable: the daemon API is not configured."); @@ -186,7 +206,9 @@ private static async Task RunSyncAsync(DaemonApi? daemonApi, TextWriter out try { output.WriteLine("Waiting for the daemon's skill sync pass. Press Ctrl+C to stop this wait."); - result = await daemonApi.SyncSkillsAsync(cancellation.Token); + result = await daemonApi.SyncSkillsAsync( + cancellation.Token, + retryRejected); } finally { @@ -210,6 +232,8 @@ private static async Task RunSyncAsync(DaemonApi? daemonApi, TextWriter out $"{source.Name}: {state} changed={source.ChangedCount} unchanged={source.UnchangedCount} rejected={source.RejectedCount} failed={source.FailedCount} sidecar={source.Sidecar}"); if (!string.IsNullOrWhiteSpace(source.Error)) output.WriteLine($" Error: {source.Error}"); + foreach (var notice in source.Notices) + output.WriteLine($" Notice: {notice}"); } output.WriteLine(result.Inventory.Succeeded @@ -733,6 +757,7 @@ private static int WriteHelp() Console.WriteLine("Subcommands:"); Console.WriteLine(" list List all discovered skills (default)"); Console.WriteLine(" sync Sync configured external skill sources"); + Console.WriteLine(" sync --retry-rejected Retry known rejected plugin commits"); Console.WriteLine(" show Show skill details and content"); Console.WriteLine(" validate Validate a SKILL.md file's frontmatter"); Console.WriteLine(" remove Remove a native skill"); @@ -745,8 +770,8 @@ private static int WriteHelp() Console.WriteLine(" source enable Enable an external source"); Console.WriteLine(" source disable Disable an external source"); Console.WriteLine(); - Console.WriteLine("`list` and `sync` need the running daemon (list includes live MCP prompt skills);"); - Console.WriteLine("every other subcommand is offline — no daemon required."); + Console.WriteLine("`list` and `sync` need the running daemon."); + Console.WriteLine("Every other subcommand is offline."); return 0; } diff --git a/tests/Netclaw.SmokeLlmServer.Tests/SmokeLlmServerTests.cs b/tests/Netclaw.SmokeLlmServer.Tests/SmokeLlmServerTests.cs index 4a7ebdd66..478e0b0c9 100644 --- a/tests/Netclaw.SmokeLlmServer.Tests/SmokeLlmServerTests.cs +++ b/tests/Netclaw.SmokeLlmServer.Tests/SmokeLlmServerTests.cs @@ -112,6 +112,41 @@ public async Task Protected_routes_require_the_configured_bearer_key_without_rec Assert.DoesNotContain(ProtectedApiKey, records, StringComparison.Ordinal); } + [Fact] + public async Task Skill_plugin_proof_requests_both_logical_skill_tools() + { + var messages = new List + { + new { role = "user", content = SmokeLlmServerHost.SkillPluginProofPrompt }, + }; + var tools = new[] + { + new { type = "function", function = new { name = "skill_load" } }, + new { type = "function", function = new { name = "skill_read_resource" } }, + }; + + var first = await PostStreamingAsync(messages, tools); + Assert.Contains("\"name\":\"skill_load\"", first, StringComparison.Ordinal); + + messages.Add(new + { + role = "tool", + tool_call_id = "call_skill_load", + content = "# Akka.NET Best Practices\nAvailable: cluster-local-abstractions.md", + }); + var second = await PostStreamingAsync(messages, tools); + Assert.Contains("\"name\":\"skill_read_resource\"", second, StringComparison.Ordinal); + + messages.Add(new + { + role = "tool", + tool_call_id = "call_skill_resource", + content = "# Cluster/Local Mode Abstractions\nGenericChildPerEntityParent", + }); + var third = await PostStreamingAsync(messages, tools); + Assert.Contains(SmokeLlmServerHost.SkillPluginProofResponse, third, StringComparison.Ordinal); + } + [Fact] public async Task Bad_request_and_non_loopback_address_fail_loudly() { @@ -168,4 +203,19 @@ public async Task Skill_feed_changes_all_content_as_one_version() } private HttpClient Client => _client ?? throw new InvalidOperationException("The test server is not initialized."); + + private async Task PostStreamingAsync( + IReadOnlyList messages, + object tools) + { + using var response = await Client.PostAsJsonAsync("/v1/chat/completions", new + { + model = SmokeLlmServerOptions.ModelId, + messages, + tools, + stream = true, + }, TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + } } diff --git a/tests/Netclaw.SmokeLlmServer/Program.cs b/tests/Netclaw.SmokeLlmServer/Program.cs index ee74b53db..6bce0a02e 100644 --- a/tests/Netclaw.SmokeLlmServer/Program.cs +++ b/tests/Netclaw.SmokeLlmServer/Program.cs @@ -81,6 +81,11 @@ public sealed record SmokeLlmServerOptions( public static class SmokeLlmServerHost { + public const string SkillPluginProofPrompt = "NETCLAW_SMOKE_SKILL_PLUGIN_PROOF"; + public const string SkillPluginProofResponse = "Skill plugin proof passed."; + private const string ProofSkillName = "akka-net-best-practices"; + private const string ProofResourcePath = "cluster-local-abstractions.md"; + public static async Task StartAsync( SmokeLlmServerOptions options, CancellationToken cancellationToken = default) @@ -226,6 +231,12 @@ await WriteErrorAsync( if (stream) { + if (IsSkillPluginProofRequest(root)) + { + await WriteSkillPluginProofAsync(context.Response, knownModel, root); + return; + } + await WriteStreamingCompletionAsync(context.Response, knownModel); return; } @@ -250,6 +261,124 @@ await context.Response.WriteAsJsonAsync(new } private static async Task WriteStreamingCompletionAsync(HttpResponse response, string model) + => await WriteStreamingTextAsync(response, model, "Netclaw smoke response."); + + private static async Task WriteSkillPluginProofAsync( + HttpResponse response, + string model, + JsonElement root) + { + var toolResults = GetToolResults(root); + if (toolResults.Count == 0) + { + if (!HasTool(root, "skill_load")) + { + await WriteStreamingTextAsync(response, model, "Skill plugin proof failed: skill_load is unavailable."); + return; + } + + await WriteStreamingToolCallAsync( + response, + model, + "call_skill_load", + "skill_load", + JsonSerializer.Serialize(new + { + Name = ProofSkillName, + _rationale = "Load the installed skill for the public plugin proof.", + })); + return; + } + + if (toolResults.Count == 1) + { + if (!toolResults[0].Contains("# Akka.NET Best Practices", StringComparison.Ordinal) + || !toolResults[0].Contains(ProofResourcePath, StringComparison.Ordinal)) + { + await WriteStreamingTextAsync(response, model, "Skill plugin proof failed: skill_load returned unexpected content."); + return; + } + if (!HasTool(root, "skill_read_resource")) + { + await WriteStreamingTextAsync(response, model, "Skill plugin proof failed: skill_read_resource is unavailable."); + return; + } + + await WriteStreamingToolCallAsync( + response, + model, + "call_skill_resource", + "skill_read_resource", + JsonSerializer.Serialize(new + { + SkillName = ProofSkillName, + ResourcePath = ProofResourcePath, + _rationale = "Read the bundled resource for the public plugin proof.", + })); + return; + } + + var resource = toolResults[^1]; + var result = resource.StartsWith("# Cluster/Local Mode Abstractions", StringComparison.Ordinal) + && resource.Contains("GenericChildPerEntityParent", StringComparison.Ordinal) + ? SkillPluginProofResponse + : "Skill plugin proof failed: skill_read_resource returned unexpected content."; + await WriteStreamingTextAsync(response, model, result); + } + + private static bool IsSkillPluginProofRequest(JsonElement root) + { + if (!root.TryGetProperty("messages", out var messages) + || messages.ValueKind is not JsonValueKind.Array) + return false; + + foreach (var message in messages.EnumerateArray()) + { + if (GetStringProperty(message, "role") == "user" + && GetStringProperty(message, "content") == SkillPluginProofPrompt) + return true; + } + + return false; + } + + private static List GetToolResults(JsonElement root) + { + var results = new List(); + if (!root.TryGetProperty("messages", out var messages) + || messages.ValueKind is not JsonValueKind.Array) + return results; + + foreach (var message in messages.EnumerateArray()) + { + if (GetStringProperty(message, "role") == "tool" + && GetStringProperty(message, "content") is { } content) + results.Add(content); + } + + return results; + } + + private static bool HasTool(JsonElement root, string name) + { + if (!root.TryGetProperty("tools", out var tools) + || tools.ValueKind is not JsonValueKind.Array) + return false; + + foreach (var tool in tools.EnumerateArray()) + { + if (tool.TryGetProperty("function", out var function) + && GetStringProperty(function, "name") == name) + return true; + } + + return false; + } + + private static async Task WriteStreamingTextAsync( + HttpResponse response, + string model, + string content) { response.StatusCode = StatusCodes.Status200OK; response.ContentType = "text/event-stream"; @@ -263,7 +392,7 @@ private static async Task WriteStreamingCompletionAsync(HttpResponse response, s model, choices = new[] { - new { index = 0, delta = new { role = "assistant", content = "Netclaw smoke response." }, finish_reason = (string?)null } + new { index = 0, delta = new { role = "assistant", content }, finish_reason = (string?)null } } }); await WriteEventAsync(response, new @@ -281,6 +410,61 @@ private static async Task WriteStreamingCompletionAsync(HttpResponse response, s await response.Body.FlushAsync(); } + private static async Task WriteStreamingToolCallAsync( + HttpResponse response, + string model, + string callId, + string name, + string arguments) + { + response.StatusCode = StatusCodes.Status200OK; + response.ContentType = "text/event-stream"; + response.Headers.CacheControl = "no-cache"; + + await WriteEventAsync(response, new + { + id = "chatcmpl-netclaw-smoke", + @object = "chat.completion.chunk", + created = 0, + model, + choices = new[] + { + new + { + index = 0, + delta = new + { + role = "assistant", + tool_calls = new[] + { + new + { + index = 0, + id = callId, + type = "function", + function = new { name, arguments }, + }, + }, + }, + finish_reason = (string?)null, + }, + }, + }); + await WriteEventAsync(response, new + { + id = "chatcmpl-netclaw-smoke", + @object = "chat.completion.chunk", + created = 0, + model, + choices = new[] + { + new { index = 0, delta = new { }, finish_reason = "tool_calls" }, + }, + }); + await response.WriteAsync("data: [DONE]\n\n"); + await response.Body.FlushAsync(); + } + private static async Task WriteEventAsync(HttpResponse response, object value) { var json = JsonSerializer.Serialize(value); diff --git a/tests/smoke/scenarios/plugin-management.sh b/tests/smoke/scenarios/plugin-management.sh new file mode 100755 index 000000000..3a504910f --- /dev/null +++ b/tests/smoke/scenarios/plugin-management.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# plugin-management.sh — prove daemon-owned plugin changes without GitHub access. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../../../scripts/smoke/lib/common.sh +. "${SCRIPT_DIR}/../../../scripts/smoke/lib/common.sh" + +NETCLAW_JSON="${NETCLAW_HOME}/config/netclaw.json" + +cleanup() { + stop_daemon +} +trap cleanup EXIT + +mkdir -p "$(dirname "$NETCLAW_JSON")" +cat >"$NETCLAW_JSON" </dev/null; then + pass "plugin remove: unrelated daemon and skill feed configuration remains present" +else + die "plugin remove: the configuration mutation changed unrelated data" +fi + +retry_status=0 +retry_output="$(nc skill sync --retry-rejected 2>&1)" || retry_status=$? +echo "$retry_output" +if [[ "$retry_status" -eq 0 && "$retry_output" == *"Inventory: ok"* ]]; then + pass "skill sync retry: the explicit retry pass completed" +else + die "skill sync retry: the explicit retry pass failed" +fi + +summarize +exit $? diff --git a/tests/smoke/scenarios/plugin-public-repository.sh b/tests/smoke/scenarios/plugin-public-repository.sh new file mode 100755 index 000000000..3bfd7b642 --- /dev/null +++ b/tests/smoke/scenarios/plugin-public-repository.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# plugin-public-repository.sh — prove a public Codex plugin through logical skill tools. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../../../scripts/smoke/lib/common.sh +. "${SCRIPT_DIR}/../../../scripts/smoke/lib/common.sh" + +PLUGIN_NAME="dotnet-skills-public" +PLUGIN_REPOSITORY="Aaronontheweb/dotnet-skills" +PLUGIN_COMMIT="13e26d39ed01d97ea592235d041304d289f4ba07" +PROOF_PROMPT="NETCLAW_SMOKE_SKILL_PLUGIN_PROOF" + +seed_and_start_daemon + +log "Install the public Codex plugin at an exact commit..." +install_output="$(nc plugin install "$PLUGIN_REPOSITORY" \ + --id "$PLUGIN_NAME" \ + --format codex \ + --commit "$PLUGIN_COMMIT" \ + --timeout-seconds 300 \ + --yes 2>&1)" || die "plugin install failed" +echo "$install_output" +if [[ "$install_output" == *"Installed plugin '${PLUGIN_NAME}' at commit ${PLUGIN_COMMIT}."* ]]; then + pass "plugin install: the CLI installed the exact public commit" +else + die "plugin install: the CLI did not report the exact public commit" +fi + +log "Check the canonical skill in the daemon inventory..." +skill_output="$(nc skill list 2>&1)" || die "skill list failed" +echo "$skill_output" +if awk '$1 == "akka-net-best-practices" && $2 == "external" { found = 1 } END { exit !found }' <<<"$skill_output"; then + pass "skill list: the daemon published the canonical external skill" +else + die "skill list: the canonical external skill is absent" +fi + +log "Load the skill and its exact bundled resource through the agent tools..." +proof_output="$(nc chat -p "$PROOF_PROMPT" 2>&1)" || die "logical skill proof failed" +if [[ "$proof_output" == *"Skill plugin proof passed."* ]]; then + pass "skill tools: skill_load and skill_read_resource returned public plugin content" +else + die "skill tools: the logical tool proof did not pass" +fi + +log "Run the external skill sync again..." +sync_output="$(nc skill sync 2>&1)" || die "second plugin sync failed" +echo "$sync_output" +if [[ "$sync_output" == *"${PLUGIN_NAME}"* && "$sync_output" == *"failed=0"* && "$sync_output" == *"rejected=0"* ]]; then + pass "skill sync: the installed public plugin stayed healthy" +else + die "skill sync: the second pass did not report a healthy plugin" +fi + +summarize +exit $?