diff --git a/feeds/skills/.system/files/netclaw-operations/references/skills.md b/feeds/skills/.system/files/netclaw-operations/references/skills.md index ccca352a1..20a0e6272 100644 --- a/feeds/skills/.system/files/netclaw-operations/references/skills.md +++ b/feeds/skills/.system/files/netclaw-operations/references/skills.md @@ -4,8 +4,10 @@ ## Skill Management -The `netclaw skill` CLI manages skills and skill sources. All subcommands -are offline — no daemon required. +The `netclaw skill` CLI manages skills and skill sources. `netclaw skill list` +needs the running daemon, because it lists the daemon's live registry — the +only view that includes dynamic MCP prompt skills. Every other subcommand is +offline — no daemon required. During an agent session, use `skill_load(name)` to activate guidance and `skill_read_resource(name, path)` for bundled files. Skill origin and physical diff --git a/src/Netclaw.Actors/Tools/SkillLoadTool.cs b/src/Netclaw.Actors/Tools/SkillLoadTool.cs index 57e07787b..5ed0b29a7 100644 --- a/src/Netclaw.Actors/Tools/SkillLoadTool.cs +++ b/src/Netclaw.Actors/Tools/SkillLoadTool.cs @@ -79,16 +79,25 @@ protected override async Task ExecuteAsync(Params args, ToolInvocationCo if (skill is null) { - // Remote prompt visibility depends on the session audience. The model - // already receives the filtered prompt index, so this fallback lists - // only file skills and cannot reveal a denied MCP server or prompt. - var available = _skillRegistry.GetAll() + // The fallback still lists only FILE skills by name: MCP prompt visibility + // is audience-filtered, and enumerating names here could reveal an MCP server + // or prompt the session is denied. But it must NOT imply MCP prompts are + // unavailable — a lookup miss on a file skill is not "no such capability". + // The pointer at the [skills] index is UNCONDITIONAL on purpose: gating it on + // the registry would leak whether any MCP prompts exist to a session whose + // audience is denied all of them, and the session's own index is already the + // audience-correct source of truth. + var fileSkills = _skillRegistry.GetAll() .Where(static candidate => candidate.Source is FileSkillSource) .Select(static candidate => candidate.Name) .ToList(); - return available.Count > 0 - ? $"Skill '{name}' not found. Available skills: {string.Join(", ", available)}" - : $"Skill '{name}' not found. No skills are currently registered."; + + var message = fileSkills.Count > 0 + ? $"Skill '{name}' not found. Available file skills: {string.Join(", ", fileSkills)}." + : $"Skill '{name}' not found. No file skills are currently registered."; + return message + + " If your [skills] index lists MCP prompt skills (mcp____), " + + "load them by that exact name."; } if (skill.Source is McpPromptSkillSource promptSource) diff --git a/src/Netclaw.Cli.Tests/Skills/SkillCommandTests.cs b/src/Netclaw.Cli.Tests/Skills/SkillCommandTests.cs new file mode 100644 index 000000000..be131297f --- /dev/null +++ b/src/Netclaw.Cli.Tests/Skills/SkillCommandTests.cs @@ -0,0 +1,214 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Net; +using System.Net.Http; +using System.Text; +using Microsoft.Extensions.Configuration; +using Netclaw.Cli.Daemon; +using Netclaw.Cli.Skills; +using Netclaw.Configuration; +using Netclaw.Tests.Utilities; +using Xunit; + +namespace Netclaw.Cli.Tests.Skills; + +/// +/// Tests that netclaw skill list is served by the daemon (so it surfaces +/// dynamic MCP prompt skills) and, per the no-silent-fallbacks rule, reports the +/// daemon as unavailable and exits non-zero when it cannot get a usable response — +/// it never degrades to a disk scan that would drop the MCP prompts, and it never +/// surfaces a stack trace for an unavailable or misconfigured daemon. +/// +public sealed class SkillCommandTests : IDisposable +{ + private const string UnavailableMarker = "Daemon unavailable"; + + private readonly DisposableTempDir _dir = new(); + private readonly NetclawPaths _paths; + private readonly StringWriter _output = new(); + + public SkillCommandTests() + { + _paths = new NetclawPaths(_dir.Path); + _paths.EnsureDirectoriesExist(); + } + + public void Dispose() + { + _output.Dispose(); + _dir.Dispose(); + } + + private DaemonApi CreateDaemonApi(Func handler) + { + var configuration = new ConfigurationBuilder().Build(); + // Nested under the test's own temp dir so Dispose cleans it up. + var paths = new NetclawPaths(Path.Combine(_dir.Path, $"api-{Guid.NewGuid():N}")); + paths.EnsureDirectoriesExist(); + return new DaemonApi(new FakeHttpClientFactory(handler), configuration, paths); + } + + private Task RunListAsync(DaemonApi? daemonApi) + => SkillCommand.RunAsync(["skill", "list"], _paths, daemonApi, output: _output); + + // ── Success paths ───────────────────────────────────────────────── + + [Fact] + public async Task List_renders_dynamic_mcp_prompt_skills_from_the_daemon() + { + var daemonApi = CreateDaemonApi(_ => FakeHttpMessageHandler.JsonResponse(new + { + skills = new object[] + { + new + { + name = "mcp__demo__hello", + displayName = "hello", + description = "A demo MCP prompt.", + source = "mcp", + serverName = "demo", + promptName = "hello", + version = (string?)null, + category = "mcp", + userInvocable = false, + modelInvocable = true, + }, + new + { + name = "commit", + displayName = "commit", + description = "A file skill.", + source = "native", + version = "1.0.0", + category = (string?)null, + userInvocable = true, + modelInvocable = true, + }, + }, + })); + + var exit = await RunListAsync(daemonApi); + + Assert.Equal(0, exit); + var text = _output.ToString(); + Assert.Contains("mcp__demo__hello", text); + // "native" is not a substring of any skill name, so seeing it proves the + // SOURCE column actually rendered (not just the name). + Assert.Contains("native", text); + Assert.DoesNotContain(UnavailableMarker, text); + } + + [Fact] + public async Task List_renders_no_skills_found_for_an_empty_inventory() + { + // An empty registry is a REAL answer from a healthy daemon — exit 0, + // never "Daemon unavailable". + var daemonApi = CreateDaemonApi(_ => FakeHttpMessageHandler.JsonResponse(new + { + skills = Array.Empty(), + })); + + var exit = await RunListAsync(daemonApi); + + Assert.Equal(0, exit); + var text = _output.ToString(); + Assert.Contains("No skills found", text); + Assert.DoesNotContain(UnavailableMarker, text); + } + + // ── Daemon-unavailable paths: report + exit 1, never a stack trace ── + + [Fact] + public async Task List_reports_daemon_unavailable_when_unreachable() + { + var daemonApi = CreateDaemonApi(_ => throw new HttpRequestException("connection refused")); + + var exit = await RunListAsync(daemonApi); + + Assert.Equal(1, exit); + Assert.Contains(UnavailableMarker, _output.ToString()); + } + + [Fact] + public async Task List_reports_daemon_unavailable_on_a_server_error_status() + { + var daemonApi = CreateDaemonApi(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError)); + + var exit = await RunListAsync(daemonApi); + + Assert.Equal(1, exit); + Assert.Contains(UnavailableMarker, _output.ToString()); + } + + [Fact] + public async Task List_explains_a_version_skewed_daemon_on_404() + { + // An updated CLI against a still-running older daemon: the route does not + // exist yet. "Start the daemon" would mislead — it must say restart instead. + var daemonApi = CreateDaemonApi(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); + + var exit = await RunListAsync(daemonApi); + + Assert.Equal(1, exit); + var text = _output.ToString(); + Assert.Contains(UnavailableMarker, text); + Assert.Contains("Restart the daemon", text); + Assert.DoesNotContain("netclaw daemon start", text); + } + + [Fact] + public async Task List_reports_daemon_unavailable_on_a_non_json_body() + { + // A foreign listener / captive portal / reverse proxy answering 200 with HTML + // makes JsonSerializer throw — the CLI must report unavailable, not crash. + var daemonApi = CreateDaemonApi(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("not a skill list", Encoding.UTF8, "text/html"), + }); + + var exit = await RunListAsync(daemonApi); + + Assert.Equal(1, exit); + Assert.Contains(UnavailableMarker, _output.ToString()); + } + + [Fact] + public async Task List_reports_daemon_unavailable_on_null_skills() + { + // {"skills":null} satisfies `required` by presence but leaves Skills null — + // the CLI must guard against it, not NRE. + var daemonApi = CreateDaemonApi(_ => FakeHttpMessageHandler.JsonResponse(new { skills = (object?)null })); + + var exit = await RunListAsync(daemonApi); + + Assert.Equal(1, exit); + Assert.Contains(UnavailableMarker, _output.ToString()); + } + + [Fact] + public async Task List_reports_daemon_unavailable_when_the_request_fails_before_http() + { + // A malformed endpoint string (e.g. NETCLAW_DAEMON_ENDPOINT=localhost:5199, + // no scheme) throws NotSupportedException/UriFormatException before any HTTP + // happens; a corrupt device token throws CryptographicException. All must + // land in the trailing catch as "Daemon unavailable", not a core dump. + var daemonApi = CreateDaemonApi(_ => throw new NotSupportedException("The 'localhost' scheme is not supported.")); + + var exit = await RunListAsync(daemonApi); + + Assert.Equal(1, exit); + Assert.Contains(UnavailableMarker, _output.ToString()); + } + + [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); + + Assert.Equal(1, exit); + Assert.Contains(UnavailableMarker, _output.ToString()); + } +} diff --git a/src/Netclaw.Cli/Daemon/DaemonApi.cs b/src/Netclaw.Cli/Daemon/DaemonApi.cs index 7956977c8..6a1eada61 100644 --- a/src/Netclaw.Cli/Daemon/DaemonApi.cs +++ b/src/Netclaw.Cli/Daemon/DaemonApi.cs @@ -141,6 +141,24 @@ public async Task> ListSessionsAsync( return await JsonSerializer.DeserializeAsync(stream, JsonDefaults.Api, cts.Token); } + // ── Skills ──────────────────────────────────────────────────────── + + /// + /// Fetches the daemon's live skill inventory — file skills plus dynamic MCP + /// prompt skills a disk scan cannot see. Throws + /// (or a timeout) when the daemon is unreachable; callers report the daemon as + /// unavailable rather than degrading to a disk scan. + /// + public async Task GetSkillsAsync(CancellationToken ct = default) + { + using var cts = CreateTimeoutCts(DefaultTimeout, ct); + var client = CreateHttpClient(); + using var response = await client.GetAsync($"{_endpoint}/api/skills", cts.Token); + response.EnsureSuccessStatusCode(); + var stream = await response.Content.ReadAsStreamAsync(cts.Token); + return await JsonSerializer.DeserializeAsync(stream, JsonDefaults.Api, cts.Token); + } + // ── Reminders ───────────────────────────────────────────────────── public async Task ListRemindersAsync(CancellationToken ct = default) diff --git a/src/Netclaw.Cli/Program.cs b/src/Netclaw.Cli/Program.cs index 479d2d6db..65ed9dd91 100644 --- a/src/Netclaw.Cli/Program.cs +++ b/src/Netclaw.Cli/Program.cs @@ -861,9 +861,41 @@ static async Task RunAsync(string[] args) // ── Skill management ── if (mode is "skill") { + var skillSubcommand = args.Length > 1 ? args[1] : "list"; + if (skillSubcommand is "list") + { + // `skill list` is served by the daemon's live registry — the only view + // that includes dynamic MCP prompt skills. It requires the daemon; when + // the daemon is unavailable, SkillCommand reports that and exits non-zero + // (no disk fallback). Building the DI host parses local config + // (netclaw.json, secrets.json); a corrupt file must produce a readable + // error, not a stack trace — the old disk-scan list never read those + // files, so this path must not make them a new way to crash. + try + { + var builder = Host.CreateApplicationBuilder(args); + ConfigureConfigServices(builder.Services, builder.Configuration); + builder.Logging.ClearProviders(); + builder.Logging.SetMinimumLevel(LogLevel.Warning); + using var skillHost = builder.Build(); + var skillPaths = skillHost.Services.GetRequiredService(); + skillPaths.EnsureDirectoriesExist(); + var skillDaemonApi = skillHost.Services.GetRequiredService(); + Environment.ExitCode = await SkillCommand.RunAsync(args, skillPaths, skillDaemonApi); + } + catch (Exception ex) when (ex is InvalidDataException or InvalidOperationException or FormatException) + { + Console.Error.WriteLine($"skill list: could not load local configuration: {ex.Message}"); + Console.Error.WriteLine("Fix the file it names (under ~/.netclaw/config) and retry."); + Environment.ExitCode = 1; + } + + return; + } + + // All other skill subcommands are offline filesystem operations — no daemon needed. var paths = new NetclawPaths(); paths.EnsureDirectoriesExist(); - // All skill subcommands are offline — no daemon needed Environment.ExitCode = await SkillCommand.RunAsync(args, paths); return; } diff --git a/src/Netclaw.Cli/Skills/SkillCommand.cs b/src/Netclaw.Cli/Skills/SkillCommand.cs index 5b0946456..26c6bdc90 100644 --- a/src/Netclaw.Cli/Skills/SkillCommand.cs +++ b/src/Netclaw.Cli/Skills/SkillCommand.cs @@ -3,22 +3,28 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- +using System.Net; using System.Text.Json; using Microsoft.Extensions.Configuration; using Netclaw.Actors.Skills; using Netclaw.Cli.Config; +using Netclaw.Cli.Daemon; using Netclaw.Cli.Json; using Netclaw.Configuration; namespace Netclaw.Cli.Skills; /// -/// Handles netclaw skill <subcommand> CLI subcommands. -/// All commands are offline — no daemon required. +/// Handles netclaw skill <subcommand> CLI subcommands. Most are offline +/// filesystem operations; list requires the running daemon, because only the +/// daemon's live registry includes the dynamic MCP prompt skills that never exist +/// on disk. When the daemon is unavailable, list reports that and exits +/// non-zero — it never degrades to a disk scan (AGENTS.md: no silent fallbacks). /// internal static class SkillCommand { - public static Task RunAsync(string[] args, NetclawPaths paths) + public static Task RunAsync( + string[] args, NetclawPaths paths, DaemonApi? daemonApi = null, TextWriter? output = null) { var subcommand = args.Length > 1 ? args[1] : "list"; @@ -42,9 +48,15 @@ public static Task RunAsync(string[] args, NetclawPaths paths) }); } + // `list` is served by the daemon's live registry — the only view that includes + // dynamic MCP prompt skills. It needs the daemon; there is no on-disk fallback, + // because a disk scan would silently drop the MCP prompts (AGENTS.md: no silent + // fallbacks). + if (subcommand is "list") + return RunListAsync(daemonApi, output ?? Console.Out); + return Task.FromResult(subcommand switch { - "list" => RunList(paths), "show" => RunShow(args, paths), "validate" => RunValidate(args), "remove" => RunRemove(args, paths), @@ -54,50 +66,104 @@ public static Task RunAsync(string[] args, NetclawPaths paths) }); } - // ── Subcommand implementations ── - - private static int RunList(NetclawPaths paths) + /// + /// Lists skills from the daemon's live registry — the only view that includes + /// dynamic MCP prompt skills. The daemon is required: when it is unreachable or + /// returns an unusable response, this reports the failure and exits non-zero + /// rather than falling back to a disk scan that would silently omit the MCP + /// prompts (AGENTS.md: no silent fallbacks). + /// + private static async Task RunListAsync(DaemonApi? daemonApi, TextWriter output) { - var result = ScanAll(paths); + string? unavailable; + var hint = "Start it with `netclaw daemon start` or `netclaw run`."; + SkillInventory.Response? inventory = null; + + if (daemonApi is null) + { + unavailable = "the daemon API is not configured"; + } + else + { + try + { + inventory = await daemonApi.GetSkillsAsync(); + unavailable = inventory?.Skills is null + ? $"the daemon at {daemonApi.Endpoint} returned an unreadable skill list" + : null; + } + catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + // The daemon is up but predates /api/skills — the normal window after a + // CLI update, before the daemon restarts. "Start it" would mislead here. + unavailable = $"the daemon at {daemonApi.Endpoint} does not serve /api/skills yet"; + hint = "Restart the daemon so it matches this CLI version."; + } + catch (HttpRequestException ex) + { + unavailable = $"could not reach the daemon at {daemonApi.Endpoint} ({ex.Message})"; + } + catch (OperationCanceledException) + { + unavailable = $"the daemon at {daemonApi.Endpoint} timed out"; + } + catch (JsonException) + { + unavailable = $"the daemon at {daemonApi.Endpoint} returned an unreadable skill list"; + } + catch (Exception ex) + { + // The request can fail BEFORE any HTTP happens: a malformed endpoint + // string (UriFormatException, NotSupportedException, + // InvalidOperationException) or a stored device token that no longer + // decrypts (CryptographicException). The endpoint and token are + // operator-editable configuration, so these are "daemon unavailable" + // reports too, not stack traces. Mirrors McpCommand.RunListAsync's + // trailing catch. + unavailable = $"the daemon request failed ({ex.Message})"; + } + } - if (result.AcceptedSkills.Count == 0 && result.Issues.Count == 0) + if (unavailable is not null) { - Console.WriteLine("No skills found."); + output.WriteLine($"Daemon unavailable: {unavailable}."); + output.WriteLine(hint); + return 1; + } + + return RenderInventory(inventory!.Skills, output); + } + + private static int RenderInventory(IReadOnlyList skills, TextWriter output) + { + if (skills.Count == 0) + { + output.WriteLine("No skills found."); return 0; } - const int colName = 24; + const int colName = 40; const int colSource = 10; const int colVersion = 10; - Console.WriteLine( + output.WriteLine( $"{"NAME",-colName} {"SOURCE",-colSource} {"VERSION",-colVersion} STATUS"); - Console.WriteLine(new string('-', colName + colSource + colVersion + 12)); + output.WriteLine(new string('-', colName + colSource + colVersion + 12)); - foreach (var skill in result.AcceptedSkills) + foreach (var skill in skills) { - var source = ClassifySource(skill, paths); var version = skill.Version ?? "-"; - - Console.WriteLine( - $"{skill.Name,-colName} {source,-colSource} {version,-colVersion} ok"); + output.WriteLine( + $"{skill.Name,-colName} {skill.Source,-colSource} {version,-colVersion} ok"); } - // Also show issues inline - foreach (var issue in result.Issues) - { - var name = issue.SkillName ?? Path.GetFileNameWithoutExtension(issue.Path); - Console.WriteLine( - $"{name,-colName} {"?",-colSource} {"-",-colVersion} {issue.Kind}"); - } - - Console.WriteLine(); - Console.WriteLine( - $"{result.AcceptedSkills.Count} skill(s), {result.Issues.Count} issue(s)"); - + output.WriteLine(); + output.WriteLine($"{skills.Count} skill(s)"); return 0; } + // ── Subcommand implementations ── + private static int RunShow(string[] args, NetclawPaths paths) { if (args.Length < 3) @@ -580,7 +646,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("All subcommands are offline — no daemon required."); + Console.WriteLine("`list` needs the running daemon (it includes live MCP prompt skills);"); + Console.WriteLine("every other subcommand is offline — no daemon required."); return 0; } diff --git a/src/Netclaw.Configuration/SkillInventory.cs b/src/Netclaw.Configuration/SkillInventory.cs new file mode 100644 index 000000000..c63e59095 --- /dev/null +++ b/src/Netclaw.Configuration/SkillInventory.cs @@ -0,0 +1,134 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- + +namespace Netclaw.Configuration; + +/// +/// Wire contract for GET /api/skills: the live skill inventory the daemon +/// has loaded. Unlike a filesystem scan, this includes DYNAMIC skills that never +/// exist as files — MCP prompt skills () and any +/// server-feed skills the daemon syncs — so an operator can see exactly what the +/// agent can load, not just what is on disk. +/// +public static class SkillInventory +{ + /// The full inventory, one row per registered skill. + public sealed class Response : IWireType + { + public required List Skills { get; init; } + } + + /// One registered skill and the metadata a client needs to present it. + public sealed class SkillRow : IWireType + { + public required string Name { get; init; } + + public required string DisplayName { get; init; } + + public required string Description { get; init; } + + /// + /// Where the skill came from: system, native, or external + /// for file skills, or mcp for a skill derived from an MCP server prompt. + /// + public required string Source { get; init; } + + public string? Category { get; init; } + + public string? Version { get; init; } + + /// Whether a user can invoke the skill with /name. + public bool UserInvocable { get; init; } + + /// Whether the model can auto-load the skill from the compressed index. + public bool ModelInvocable { get; init; } + + /// Human-readable argument hint (e.g. <property> [days]), when the skill takes arguments. + public string? ArgumentHint { get; init; } + + /// The MCP server that owns the prompt, when is mcp. + public string? ServerName { get; init; } + + /// The underlying MCP prompt name (before the mcp__server__ prefix), when is mcp. + public string? PromptName { get; init; } + + /// The prompt's declared arguments, when is mcp. Null for file skills. + public List? Arguments { get; init; } + } + + /// A single declared argument of an MCP prompt skill. + public sealed class SkillArgument : IWireType + { + public required string Name { get; init; } + + public string? Description { get; init; } + + public bool Required { get; init; } + } + + /// + /// Projects the daemon's live skill registry into the wire response. The source + /// label for file skills is derived from the skill's path (mirroring the CLI's + /// offline classification); MCP prompt skills report mcp plus their server, + /// prompt name, and declared arguments. + /// + public static Response From(IEnumerable skills, NetclawPaths paths) + { + var systemPrefix = paths.SystemSkillsDirectory + Path.DirectorySeparatorChar; + var nativePrefix = paths.SkillsDirectory + Path.DirectorySeparatorChar; + + var rows = skills + .OrderBy(static skill => skill.Name, StringComparer.Ordinal) + .Select(skill => + { + var mcp = skill.Source as McpPromptSkillSource; + return new SkillRow + { + Name = skill.Name, + DisplayName = skill.DisplayName, + Description = skill.Description, + Source = Classify(skill, systemPrefix, nativePrefix), + Category = skill.Category, + Version = skill.Version, + UserInvocable = skill.UserInvocable, + ModelInvocable = !skill.DisableModelInvocation, + ArgumentHint = skill.ArgumentHint, + ServerName = mcp?.ServerName, + PromptName = mcp?.PromptName, + Arguments = mcp is null + ? null + : mcp.Arguments + .Select(static argument => new SkillArgument + { + Name = argument.Name, + Description = argument.Description, + Required = argument.Required, + }) + .ToList(), + }; + }) + .ToList(); + + return new Response { Skills = rows }; + } + + private static string Classify(SkillEntry skill, string systemPrefix, string nativePrefix) + { + switch (skill.Source) + { + case McpPromptSkillSource: + return "mcp"; + case FileSkillSource file when file.FilePath.StartsWith(systemPrefix, StringComparison.OrdinalIgnoreCase): + return "system"; + case FileSkillSource file when file.FilePath.StartsWith(nativePrefix, StringComparison.OrdinalIgnoreCase): + return "native"; + case FileSkillSource: + return "external"; + default: + return "unknown"; + } + } +} diff --git a/src/Netclaw.Daemon.Tests/Skills/SkillEndpointRouteBuilderExtensionsTests.cs b/src/Netclaw.Daemon.Tests/Skills/SkillEndpointRouteBuilderExtensionsTests.cs new file mode 100644 index 000000000..a9b4f973d --- /dev/null +++ b/src/Netclaw.Daemon.Tests/Skills/SkillEndpointRouteBuilderExtensionsTests.cs @@ -0,0 +1,145 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Net; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Netclaw.Actors.Skills; +using Netclaw.Configuration; +using Netclaw.Daemon.Security; +using Netclaw.Daemon.Skills; +using Netclaw.Tests.Utilities; +using Xunit; + +namespace Netclaw.Daemon.Tests.Skills; + +/// +/// Integration tests for GET /api/skills +/// (). The test +/// host calls the real extension method — no handler reimplementation — and the +/// registry is seeded with both a file skill and a dynamic MCP prompt skill. +/// +public sealed class SkillEndpointRouteBuilderExtensionsTests : IDisposable +{ + private static readonly JsonSerializerOptions ReadOptions = new(JsonSerializerDefaults.Web); + + private readonly DisposableTempDir _dir = new(); + + public void Dispose() => _dir.Dispose(); + + private async Task CreateAppAsync(bool spoofLoopback, SkillRegistry registry, NetclawPaths paths) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + builder.Services.AddNetclawAuthSchemes(new DaemonConfig()); + builder.Services.AddAuthorization(); + builder.Services.AddLogging(); + builder.Services.AddSingleton(registry); + builder.Services.AddSingleton(paths); + + var app = builder.Build(); + + if (spoofLoopback) + { + app.Use(async (ctx, next) => + { + ctx.Connection.RemoteIpAddress = IPAddress.Loopback; + await next(ctx); + }); + } + + app.UseAuthentication(); + app.UseAuthorization(); + app.MapSkillEndpoints(); + + await app.StartAsync(TestContext.Current.CancellationToken); + return app; + } + + [Fact] + public async Task RequiresAuthorization_returns_401_for_unauthenticated_request() + { + var ct = TestContext.Current.CancellationToken; + var paths = new NetclawPaths(_dir.Path); + await using var app = await CreateAppAsync(spoofLoopback: false, new SkillRegistry(), paths); + var client = app.GetTestClient(); + + var response = await client.GetAsync("/api/skills", ct); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Returns_dynamic_mcp_prompt_skills_that_a_disk_scan_cannot_see() + { + var ct = TestContext.Current.CancellationToken; + var paths = new NetclawPaths(_dir.Path); + paths.EnsureDirectoriesExist(); + + var registry = new SkillRegistry(); + + // A file skill under the native skills directory. + var fileSkill = new SkillEntry( + "demo-file", + "Demo File", + "A file-backed skill.", + new FileSkillSource( + Path.Combine(paths.SkillsDirectory, "demo-file", "SKILL.md"), + Path.Combine(paths.SkillsDirectory, "demo-file")), + Category: null); + registry.ReplaceAll([fileSkill]); + + // A dynamic MCP prompt skill — exists only in memory, never on disk. + var mcpSkill = new SkillEntry( + "mcp__demo__hello", + "hello", + "A demo MCP prompt.", + new McpPromptSkillSource( + "demo", + "hello", + Generation: 1, + Arguments: [new SkillArgumentDescriptor("property", "The property slug.", Required: true)]), + Category: "mcp") + { + UserInvocable = false, + ArgumentHint = "", + }; + registry.PublishMcpPromptSkills("demo", [mcpSkill]); + + await using var app = await CreateAppAsync(spoofLoopback: true, registry, paths); + var client = app.GetTestClient(); + + var response = await client.GetAsync("/api/skills", ct); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var json = await response.Content.ReadAsStringAsync(ct); + var inventory = JsonSerializer.Deserialize(json, ReadOptions); + Assert.NotNull(inventory); + + // The MCP prompt skill is present, tagged as its dynamic source, with the + // metadata a client needs to present it. + var mcp = Assert.Single(inventory!.Skills, s => s.Name == "mcp__demo__hello"); + Assert.Equal("mcp", mcp.Source); + Assert.Equal("demo", mcp.ServerName); + Assert.Equal("hello", mcp.PromptName); + Assert.Equal("A demo MCP prompt.", mcp.Description); + Assert.Equal("", mcp.ArgumentHint); + Assert.False(mcp.UserInvocable); // hidden from /name invocation + Assert.True(mcp.ModelInvocable); // still in the model's compressed index + + var arg = Assert.Single(mcp.Arguments!); + Assert.Equal("property", arg.Name); + Assert.True(arg.Required); + + // The file skill is present too, classified by its path. + var file = Assert.Single(inventory.Skills, s => s.Name == "demo-file"); + Assert.Equal("native", file.Source); + Assert.Null(file.ServerName); + } +} diff --git a/src/Netclaw.Daemon/Program.cs b/src/Netclaw.Daemon/Program.cs index b5a649225..a2fe79058 100644 --- a/src/Netclaw.Daemon/Program.cs +++ b/src/Netclaw.Daemon/Program.cs @@ -45,6 +45,7 @@ using Netclaw.Daemon.Services; using Netclaw.Daemon.Lifecycle; using Netclaw.Daemon.Reminders; +using Netclaw.Daemon.Skills; using Netclaw.Daemon.Webhooks; using Netclaw.Search; using Netclaw.Tools; @@ -321,6 +322,8 @@ static async Task RunDaemonAsync( app.MapMcpEndpoints(); + app.MapSkillEndpoints(); + app.MapProviderOAuthEndpoints(); app.MapLifecycleEndpoints(); diff --git a/src/Netclaw.Daemon/Skills/SkillEndpointRouteBuilderExtensions.cs b/src/Netclaw.Daemon/Skills/SkillEndpointRouteBuilderExtensions.cs new file mode 100644 index 000000000..af44203b0 --- /dev/null +++ b/src/Netclaw.Daemon/Skills/SkillEndpointRouteBuilderExtensions.cs @@ -0,0 +1,33 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Netclaw.Actors.Skills; +using Netclaw.Configuration; + +namespace Netclaw.Daemon.Skills; + +/// +/// Read endpoint over the daemon's live . This is the +/// authoritative source of what an agent can actually load — file skills PLUS the +/// dynamic MCP prompt skills a filesystem scan can never see. The CLI's +/// skill list is served by this endpoint and requires the daemon; there is +/// no disk fallback. +/// +public static class SkillEndpointRouteBuilderExtensions +{ + public static void MapSkillEndpoints(this WebApplication app) + { + app.MapGet("/api/skills", (SkillRegistry registry, NetclawPaths paths) => + (Ok)TypedResults.Ok( + SkillInventory.From(registry.GetAll(), paths))) + .WithName("ListSkills") + .WithSummary("List every skill the daemon has loaded, including dynamic MCP prompt skills.") + .WithTags("Skills") + .RequireAuthorization(); + } +}