Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 16 additions & 7 deletions src/Netclaw.Actors/Tools/SkillLoadTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,16 +79,25 @@ protected override async Task<string> 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__<server>__<prompt>), "
+ "load them by that exact name.";
}

if (skill.Source is McpPromptSkillSource promptSource)
Expand Down
214 changes: 214 additions & 0 deletions src/Netclaw.Cli.Tests/Skills/SkillCommandTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
// -----------------------------------------------------------------------
// <copyright file="SkillCommandTests.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
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;

/// <summary>
/// Tests that <c>netclaw skill list</c> 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.
/// </summary>
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<HttpRequestMessage, HttpResponseMessage> 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<int> 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<object>(),
}));

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("<html>not a skill list</html>", 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());
}
}
18 changes: 18 additions & 0 deletions src/Netclaw.Cli/Daemon/DaemonApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,24 @@ public async Task<List<SessionCatalogEntryDto>> ListSessionsAsync(
return await JsonSerializer.DeserializeAsync<SkillUsageStats.Response>(stream, JsonDefaults.Api, cts.Token);
}

// ── Skills ────────────────────────────────────────────────────────

/// <summary>
/// Fetches the daemon's live skill inventory — file skills plus dynamic MCP
/// prompt skills a disk scan cannot see. Throws <see cref="HttpRequestException"/>
/// (or a timeout) when the daemon is unreachable; callers report the daemon as
/// unavailable rather than degrading to a disk scan.
/// </summary>
public async Task<SkillInventory.Response?> GetSkillsAsync(CancellationToken ct = default)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

{
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<SkillInventory.Response>(stream, JsonDefaults.Api, cts.Token);
}

// ── Reminders ─────────────────────────────────────────────────────

public async Task<HttpResponseMessage> ListRemindersAsync(CancellationToken ct = default)
Expand Down
34 changes: 33 additions & 1 deletion src/Netclaw.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<NetclawPaths>();
skillPaths.EnsureDirectoriesExist();
var skillDaemonApi = skillHost.Services.GetRequiredService<DaemonApi>();
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;
}
Expand Down
Loading
Loading