From 9e3600a7819337d3e1d3fd52f9ae8fafd46ef2a8 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sat, 11 Apr 2026 15:21:14 +0000 Subject: [PATCH 1/4] fix(skills): resolve claude-code plugin marketplaces, drop commands scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExternalSkillsConfig.ResolveEnabledSources now enumerates every installed Claude Code plugin marketplace under ~/.claude/plugins/marketplaces/*/skills/ whenever the claude-code well-known source is being resolved. On a typical dev machine this picks up 33 skills from the dotnet-skills marketplace (akka-best-practices, csharp-coding-standards, csharp-concurrency- patterns, efcore-patterns, slopwatch, and more) plus anything else installed, so agents working in C#/.NET sessions can actually see skills relevant to the task instead of guessing at skill_load names. The version cache under ~/.claude/plugins/cache/ is intentionally skipped: Claude Code reads the live marketplace path at runtime, and scanning the cache would double-count every skill. The enumeration is dynamic rather than driven by known_marketplaces.json so we stay decoupled from Claude Code's metadata format. Also drops ~/.claude/commands from the claude-code well-known catalog. Those files are Claude Code user slash commands with a different frontmatter schema — scanning them was producing ~25 FlatFileMissingFrontmatter warnings on every sync rebuild with no skills recovered, hiding real degraded-inventory signals in the daemon log. Expected effect on a daemon restart against a machine with dotnet-skills installed: "accepted=62 rejected=31" shifts to roughly "accepted=~95 rejected=~6". Tests: - ExternalSkillsConfigTests rewritten to cover the new marketplace expansion (no-marketplaces, multi-marketplace, skip marketplaces missing a skills/ subdir, marketplace-only when the primary .claude/skills is absent, no crash when the marketplaces root is missing, expansion scoped to the claude-code alias only) plus a regression case that ProbeWellKnownSources never surfaces ~/.claude/commands. - SkillRegistryTests multi-path example updated to use a marketplace path instead of the removed commands path. - SkillDirectoryWatcherService comment reflects the new semantics. Grounding follow-ups tracked under milestone 0.12: #591 feed 404s, #592 enrichment silent failure, #593 stats counter restart reset, #594 skill_load tool counter gap. --- .../Skills/SkillRegistryTests.cs | 10 +- .../ExternalSkillsConfigTests.cs | 144 +++++++++++++++--- .../ExternalSkillsConfig.cs | 72 ++++++++- .../Services/SkillDirectoryWatcherService.cs | 3 +- 4 files changed, 199 insertions(+), 30 deletions(-) diff --git a/src/Netclaw.Actors.Tests/Skills/SkillRegistryTests.cs b/src/Netclaw.Actors.Tests/Skills/SkillRegistryTests.cs index 8b43b4929..bbcb3880e 100644 --- a/src/Netclaw.Actors.Tests/Skills/SkillRegistryTests.cs +++ b/src/Netclaw.Actors.Tests/Skills/SkillRegistryTests.cs @@ -274,13 +274,19 @@ public void GenerateIndex_with_multi_path_external_source_joins_paths_with_semic { new ResolvedExternalSource( "claude-code", - new[] { "/home/user/.claude/skills", "/home/user/.claude/commands" }, + new[] + { + "/home/user/.claude/skills", + "/home/user/.claude/plugins/marketplaces/dotnet-skills/skills" + }, true) }; var index = registry.GenerateIndex("/home/user/.netclaw/skills", externalSources); - Assert.Contains("claude-code=/home/user/.claude/skills;/home/user/.claude/commands", index); + Assert.Contains( + "claude-code=/home/user/.claude/skills;/home/user/.claude/plugins/marketplaces/dotnet-skills/skills", + index); } [Fact] diff --git a/src/Netclaw.Configuration.Tests/ExternalSkillsConfigTests.cs b/src/Netclaw.Configuration.Tests/ExternalSkillsConfigTests.cs index b3b0585ae..2e4253063 100644 --- a/src/Netclaw.Configuration.Tests/ExternalSkillsConfigTests.cs +++ b/src/Netclaw.Configuration.Tests/ExternalSkillsConfigTests.cs @@ -19,13 +19,17 @@ public void Dispose() Directory.Delete(_homeDir, recursive: true); } + private static string ClaudeSkillsPath(string home) => Path.Combine(home, ".claude", "skills"); + + private static string MarketplacesRoot(string home) => Path.Combine(home, ".claude", "plugins", "marketplaces"); + + private static string MarketplaceSkillsPath(string home, string marketplace) => + Path.Combine(home, ".claude", "plugins", "marketplaces", marketplace, "skills"); + [Fact] - public void ClaudeCode_resolves_to_one_source_with_both_paths_when_skills_and_commands_exist() + public void ClaudeCode_resolves_only_the_skills_path_when_no_marketplaces_installed() { - var skillsDir = Path.Combine(_homeDir, ".claude", "skills"); - var commandsDir = Path.Combine(_homeDir, ".claude", "commands"); - Directory.CreateDirectory(skillsDir); - Directory.CreateDirectory(commandsDir); + Directory.CreateDirectory(ClaudeSkillsPath(_homeDir)); var config = new ExternalSkillsConfig { @@ -39,15 +43,66 @@ public void ClaudeCode_resolves_to_one_source_with_both_paths_when_skills_and_co var source = Assert.Single(resolved); Assert.Equal("claude-code", source.Name); + Assert.Single(source.Paths); + Assert.EndsWith(Path.Combine(".claude", "skills"), source.Paths[0]); + } + + [Fact] + public void ClaudeCode_expands_every_installed_marketplace_with_a_skills_subdir() + { + Directory.CreateDirectory(ClaudeSkillsPath(_homeDir)); + Directory.CreateDirectory(MarketplaceSkillsPath(_homeDir, "dotnet-skills")); + Directory.CreateDirectory(MarketplaceSkillsPath(_homeDir, "prose")); + + var config = new ExternalSkillsConfig + { + Sources = + { + new ExternalSkillSource { Name = "claude-code", WellKnown = "claude-code", Enabled = true } + } + }; + + var resolved = config.ResolveEnabledSources(_homeDir); + + var source = Assert.Single(resolved); + Assert.Equal(3, source.Paths.Count); + Assert.Contains(source.Paths, p => p.EndsWith(Path.Combine(".claude", "skills"), StringComparison.Ordinal)); + Assert.Contains(source.Paths, p => p.EndsWith(Path.Combine("marketplaces", "dotnet-skills", "skills"), StringComparison.Ordinal)); + Assert.Contains(source.Paths, p => p.EndsWith(Path.Combine("marketplaces", "prose", "skills"), StringComparison.Ordinal)); + } + + [Fact] + public void ClaudeCode_skips_marketplaces_that_have_no_skills_subdir() + { + Directory.CreateDirectory(ClaudeSkillsPath(_homeDir)); + // Populated marketplace with a skills/ subdir + Directory.CreateDirectory(MarketplaceSkillsPath(_homeDir, "dotnet-skills")); + // Marketplace dir exists but has no skills/ subdir — should be silently skipped + Directory.CreateDirectory(Path.Combine(MarketplacesRoot(_homeDir), "empty-marketplace")); + + var config = new ExternalSkillsConfig + { + Sources = + { + new ExternalSkillSource { Name = "claude-code", WellKnown = "claude-code", Enabled = true } + } + }; + + var resolved = config.ResolveEnabledSources(_homeDir); + + var source = Assert.Single(resolved); Assert.Equal(2, source.Paths.Count); Assert.Contains(source.Paths, p => p.EndsWith(Path.Combine(".claude", "skills"), StringComparison.Ordinal)); - Assert.Contains(source.Paths, p => p.EndsWith(Path.Combine(".claude", "commands"), StringComparison.Ordinal)); + Assert.Contains(source.Paths, p => p.EndsWith(Path.Combine("marketplaces", "dotnet-skills", "skills"), StringComparison.Ordinal)); + Assert.DoesNotContain(source.Paths, p => p.Contains("empty-marketplace", StringComparison.Ordinal)); } [Fact] - public void ClaudeCode_drops_missing_commands_path_silently() + public void ClaudeCode_resolves_marketplace_only_when_primary_skills_dir_is_missing() { - Directory.CreateDirectory(Path.Combine(_homeDir, ".claude", "skills")); + // No ~/.claude/skills directory — user has Claude Code plugins but never created a + // bare skills/ dir. The claude-code source should still resolve from the marketplace alone. + Directory.CreateDirectory(MarketplaceSkillsPath(_homeDir, "dotnet-skills")); var config = new ExternalSkillsConfig { @@ -61,13 +116,14 @@ public void ClaudeCode_drops_missing_commands_path_silently() var source = Assert.Single(resolved); Assert.Single(source.Paths); - Assert.EndsWith(Path.Combine(".claude", "skills"), source.Paths[0]); + Assert.EndsWith(Path.Combine("marketplaces", "dotnet-skills", "skills"), source.Paths[0]); } [Fact] - public void ClaudeCode_resolves_when_only_commands_dir_exists() + public void ClaudeCode_does_not_crash_when_marketplaces_root_is_missing() { - Directory.CreateDirectory(Path.Combine(_homeDir, ".claude", "commands")); + Directory.CreateDirectory(ClaudeSkillsPath(_homeDir)); + // Intentionally no .claude/plugins/marketplaces at all var config = new ExternalSkillsConfig { @@ -81,13 +137,59 @@ public void ClaudeCode_resolves_when_only_commands_dir_exists() var source = Assert.Single(resolved); Assert.Single(source.Paths); - Assert.EndsWith(Path.Combine(".claude", "commands"), source.Paths[0]); + Assert.EndsWith(Path.Combine(".claude", "skills"), source.Paths[0]); + } + + [Fact] + public void Marketplace_expansion_does_not_apply_to_open_code() + { + Directory.CreateDirectory(Path.Combine(_homeDir, ".open-code", "skills")); + // Marketplaces exist under ~/.claude but this source is open-code, not claude-code. + Directory.CreateDirectory(MarketplaceSkillsPath(_homeDir, "dotnet-skills")); + + var config = new ExternalSkillsConfig + { + Sources = + { + new ExternalSkillSource { Name = "open-code", WellKnown = "open-code", Enabled = true } + } + }; + + var resolved = config.ResolveEnabledSources(_homeDir); + + var source = Assert.Single(resolved); + Assert.Single(source.Paths); + Assert.EndsWith(Path.Combine(".open-code", "skills"), source.Paths[0]); + } + + [Fact] + public void Marketplace_expansion_does_not_apply_to_custom_path_sources() + { + var customDir = Path.Combine(_homeDir, "team-skills"); + Directory.CreateDirectory(customDir); + // Marketplaces exist but this source uses a custom Path, not WellKnown=claude-code. + Directory.CreateDirectory(MarketplaceSkillsPath(_homeDir, "dotnet-skills")); + + var config = new ExternalSkillsConfig + { + Sources = + { + new ExternalSkillSource { Name = "team", Path = customDir, Enabled = true } + } + }; + + var resolved = config.ResolveEnabledSources(_homeDir); + + var source = Assert.Single(resolved); + Assert.Single(source.Paths); + Assert.Equal(customDir, source.Paths[0]); } [Fact] public void Disabled_source_is_skipped_even_if_paths_exist() { - Directory.CreateDirectory(Path.Combine(_homeDir, ".claude", "skills")); + Directory.CreateDirectory(ClaudeSkillsPath(_homeDir)); + Directory.CreateDirectory(MarketplaceSkillsPath(_homeDir, "dotnet-skills")); var config = new ExternalSkillsConfig { @@ -105,13 +207,12 @@ public void Disabled_source_is_skipped_even_if_paths_exist() [Fact] public void Probe_returns_one_result_per_alias_when_any_path_exists() { - Directory.CreateDirectory(Path.Combine(_homeDir, ".claude", "skills")); + Directory.CreateDirectory(ClaudeSkillsPath(_homeDir)); var probed = ExternalSkillsConfig.ProbeWellKnownSources(_homeDir); var result = Assert.Single(probed); Assert.Equal("claude-code", result.WellKnownAlias); - // Primary (skills) preferred when it exists Assert.EndsWith(Path.Combine(".claude", "skills"), result.ResolvedPath); } @@ -124,24 +225,25 @@ public void Probe_returns_no_results_when_no_paths_exist() } [Fact] - public void Probe_uses_commands_dir_as_fallback_when_primary_skills_missing() + public void Probe_does_not_surface_commands_directory_as_a_skill_source() { + // Claude Code's flat slash-command files live at ~/.claude/commands. They + // use a different frontmatter schema and must not be reported as a + // valid claude-code source even when the skills/ dir is absent. Directory.CreateDirectory(Path.Combine(_homeDir, ".claude", "commands")); var probed = ExternalSkillsConfig.ProbeWellKnownSources(_homeDir); - var result = Assert.Single(probed); - Assert.EndsWith(Path.Combine(".claude", "commands"), result.ResolvedPath); + Assert.Empty(probed); } [Fact] - public void ResolveWellKnownPaths_returns_all_paths_for_claude_code() + public void ResolveWellKnownPaths_returns_only_the_skills_path_for_claude_code() { var paths = ExternalSkillsConfig.ResolveWellKnownPaths("claude-code", _homeDir); - Assert.Equal(2, paths.Count); + Assert.Single(paths); Assert.EndsWith(Path.Combine(".claude", "skills"), paths[0]); - Assert.EndsWith(Path.Combine(".claude", "commands"), paths[1]); } [Fact] diff --git a/src/Netclaw.Configuration/ExternalSkillsConfig.cs b/src/Netclaw.Configuration/ExternalSkillsConfig.cs index a4c08e473..5dd17f71c 100644 --- a/src/Netclaw.Configuration/ExternalSkillsConfig.cs +++ b/src/Netclaw.Configuration/ExternalSkillsConfig.cs @@ -10,22 +10,42 @@ public sealed class ExternalSkillsConfig /// Single catalog of well-known external skill sources. Both /// and /// consume this so alias/display/symlink metadata stays in one place. - /// Each alias can own multiple relative paths — e.g. claude-code - /// scans both ~/.claude/skills/ and ~/.claude/commands/ - /// because Claude Code user slash commands live alongside skills and - /// share the same YAML-frontmatter format. The first path is the primary - /// (used for display/validation); all existing paths are scanned. + /// Each alias can own multiple relative paths — the first path is the + /// primary (used for display/validation); all existing paths are scanned. /// + /// + /// The claude-code alias is also expanded at resolution time by + /// to include every installed + /// plugin marketplace under ~/.claude/plugins/marketplaces/*/skills/, + /// so marketplace skills (e.g. the dotnet-skills plugin) show up without + /// needing a separate configured source. That expansion is dynamic and + /// lives outside the static catalog. + /// private static readonly (string Alias, string DisplayName, string[] RelativePaths, bool DefaultAllowSymlinks)[] WellKnownCatalog = [ ("claude-code", "Claude Code", - new[] { Path.Combine(".claude", "skills"), Path.Combine(".claude", "commands") }, + new[] { Path.Combine(".claude", "skills") }, true), ("open-code", "Open Code", new[] { Path.Combine(".open-code", "skills") }, false) ]; + /// + /// Well-known alias whose resolution also enumerates Claude Code plugin + /// marketplaces. Kept as a constant so the dynamic-expansion branch in + /// stays discoverable. + /// + internal const string ClaudeCodeAlias = "claude-code"; + + /// + /// Relative path from the home directory to the Claude Code plugins root + /// whose subdirectories each contain a skills/ folder for an + /// installed marketplace. + /// + private static readonly string ClaudeCodeMarketplacesRelativePath = + Path.Combine(".claude", "plugins", "marketplaces"); + /// /// Ordered list of external skill sources. Precedence follows list order — /// earlier sources win on name collisions (native Netclaw skills always take @@ -59,6 +79,13 @@ internal IReadOnlyList ResolveEnabledSources(string home ? ResolveWellKnownPaths(source.WellKnown, homeDirectory) : source.Path is not null ? new[] { source.Path } : Array.Empty(); + if (string.Equals(source.WellKnown, ClaudeCodeAlias, StringComparison.OrdinalIgnoreCase)) + { + var marketplacePaths = EnumerateClaudeCodeMarketplaceSkillPaths(homeDirectory); + if (marketplacePaths.Count > 0) + candidatePaths = candidatePaths.Concat(marketplacePaths).ToList(); + } + var existingPaths = new List(); foreach (var candidate in candidatePaths) { @@ -81,6 +108,39 @@ internal IReadOnlyList ResolveEnabledSources(string home return results; } + /// + /// Enumerates the live skills/ directories of every Claude Code plugin + /// marketplace installed under ~/.claude/plugins/marketplaces/. Returns + /// an empty list if the marketplaces root doesn't exist or if no installed + /// marketplace has a skills/ subdirectory. The filesystem is the source + /// of truth — we intentionally don't parse known_marketplaces.json or + /// installed_plugins.json so Netclaw stays decoupled from Claude Code's + /// plugin metadata format. The version cache at plugins/cache/ is + /// skipped because Claude Code itself reads the live marketplace path at + /// runtime; scanning the cache would duplicate entries. + /// + private static IReadOnlyList EnumerateClaudeCodeMarketplaceSkillPaths(string homeDirectory) + { + var marketplacesRoot = Path.Combine(homeDirectory, ClaudeCodeMarketplacesRelativePath); + if (!Directory.Exists(marketplacesRoot)) + return Array.Empty(); + + try + { + return Directory.EnumerateDirectories(marketplacesRoot) + .Select(d => Path.Combine(d, "skills")) + .ToList(); + } + catch (UnauthorizedAccessException) + { + return Array.Empty(); + } + catch (DirectoryNotFoundException) + { + return Array.Empty(); + } + } + /// /// Probes the filesystem for well-known external skill directories and returns /// those where at least one configured path exists on disk. diff --git a/src/Netclaw.Daemon/Services/SkillDirectoryWatcherService.cs b/src/Netclaw.Daemon/Services/SkillDirectoryWatcherService.cs index a3919d4a4..9929e06ff 100644 --- a/src/Netclaw.Daemon/Services/SkillDirectoryWatcherService.cs +++ b/src/Netclaw.Daemon/Services/SkillDirectoryWatcherService.cs @@ -47,7 +47,8 @@ protected override Task ExecuteAsync(CancellationToken stoppingToken) TryCreateWatcher(_paths.SkillsDirectory, "native"); // Watch each external source directory. A single source may cover multiple - // paths (e.g. claude-code = ~/.claude/skills + ~/.claude/commands). + // paths (e.g. claude-code = ~/.claude/skills plus one path per installed + // plugin marketplace under ~/.claude/plugins/marketplaces/*/skills/). foreach (var source in _externalSources) { foreach (var path in source.Paths) From 8f611362374aeac18c377689821698d0a4be08d1 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sat, 11 Apr 2026 15:29:48 +0000 Subject: [PATCH 2/4] refactor(skills): tighten ExternalSkillsConfig marketplace helper Post-review cleanup on 9e3600a: - WellKnownCatalog now references ClaudeCodeAlias instead of the bare string literal, so the catalog entry and the dynamic-expansion branch in ResolveEnabledSources share a single source of truth. - Dropped the DirectoryNotFoundException catch in EnumerateClaudeCodeMarketplaceSkillPaths. The Directory.Exists pre-check already handles the missing-root case, and the remaining TOCTOU race window on a cold startup path isn't worth a silent fallback. UnauthorizedAccessException is kept because permissions are a real operational condition. - Trimmed the XML doc on the marketplace helper to keep only the non-obvious "why": why filesystem is the source of truth (not known_marketplaces.json) and why the version cache is skipped. - Removed three narration comments from the test file that just restated the test names. --- .../ExternalSkillsConfigTests.cs | 3 --- .../ExternalSkillsConfig.cs | 23 ++++++++----------- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/src/Netclaw.Configuration.Tests/ExternalSkillsConfigTests.cs b/src/Netclaw.Configuration.Tests/ExternalSkillsConfigTests.cs index 2e4253063..ff0d6a4ea 100644 --- a/src/Netclaw.Configuration.Tests/ExternalSkillsConfigTests.cs +++ b/src/Netclaw.Configuration.Tests/ExternalSkillsConfigTests.cs @@ -75,9 +75,7 @@ public void ClaudeCode_expands_every_installed_marketplace_with_a_skills_subdir( public void ClaudeCode_skips_marketplaces_that_have_no_skills_subdir() { Directory.CreateDirectory(ClaudeSkillsPath(_homeDir)); - // Populated marketplace with a skills/ subdir Directory.CreateDirectory(MarketplaceSkillsPath(_homeDir, "dotnet-skills")); - // Marketplace dir exists but has no skills/ subdir — should be silently skipped Directory.CreateDirectory(Path.Combine(MarketplacesRoot(_homeDir), "empty-marketplace")); var config = new ExternalSkillsConfig @@ -123,7 +121,6 @@ public void ClaudeCode_resolves_marketplace_only_when_primary_skills_dir_is_miss public void ClaudeCode_does_not_crash_when_marketplaces_root_is_missing() { Directory.CreateDirectory(ClaudeSkillsPath(_homeDir)); - // Intentionally no .claude/plugins/marketplaces at all var config = new ExternalSkillsConfig { diff --git a/src/Netclaw.Configuration/ExternalSkillsConfig.cs b/src/Netclaw.Configuration/ExternalSkillsConfig.cs index 5dd17f71c..32ef5182a 100644 --- a/src/Netclaw.Configuration/ExternalSkillsConfig.cs +++ b/src/Netclaw.Configuration/ExternalSkillsConfig.cs @@ -23,7 +23,7 @@ public sealed class ExternalSkillsConfig /// private static readonly (string Alias, string DisplayName, string[] RelativePaths, bool DefaultAllowSymlinks)[] WellKnownCatalog = [ - ("claude-code", "Claude Code", + (ClaudeCodeAlias, "Claude Code", new[] { Path.Combine(".claude", "skills") }, true), ("open-code", "Open Code", @@ -109,15 +109,14 @@ internal IReadOnlyList ResolveEnabledSources(string home } /// - /// Enumerates the live skills/ directories of every Claude Code plugin - /// marketplace installed under ~/.claude/plugins/marketplaces/. Returns - /// an empty list if the marketplaces root doesn't exist or if no installed - /// marketplace has a skills/ subdirectory. The filesystem is the source - /// of truth — we intentionally don't parse known_marketplaces.json or - /// installed_plugins.json so Netclaw stays decoupled from Claude Code's - /// plugin metadata format. The version cache at plugins/cache/ is - /// skipped because Claude Code itself reads the live marketplace path at - /// runtime; scanning the cache would duplicate entries. + /// Enumerates the live skills/ directories of every Claude Code + /// plugin marketplace installed under ~/.claude/plugins/marketplaces/. + /// The filesystem is the source of truth — we intentionally don't parse + /// known_marketplaces.json or installed_plugins.json so + /// Netclaw stays decoupled from Claude Code's plugin metadata format. The + /// version cache at plugins/cache/ is skipped because Claude Code + /// itself reads the live marketplace path at runtime; scanning the cache + /// would duplicate entries. /// private static IReadOnlyList EnumerateClaudeCodeMarketplaceSkillPaths(string homeDirectory) { @@ -135,10 +134,6 @@ private static IReadOnlyList EnumerateClaudeCodeMarketplaceSkillPaths(st { return Array.Empty(); } - catch (DirectoryNotFoundException) - { - return Array.Empty(); - } } /// From e68f849a8be535b6317ce1a9e08010f1295acd5e Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sat, 11 Apr 2026 15:52:41 +0000 Subject: [PATCH 3/4] fix(skills): align claude-code probe with marketplace resolution --- .../ExternalSkillsConfigTests.cs | 36 +++++++++++++++++++ .../ExternalSkillsConfig.cs | 32 +++++++++++------ 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/src/Netclaw.Configuration.Tests/ExternalSkillsConfigTests.cs b/src/Netclaw.Configuration.Tests/ExternalSkillsConfigTests.cs index ff0d6a4ea..a53ec7e39 100644 --- a/src/Netclaw.Configuration.Tests/ExternalSkillsConfigTests.cs +++ b/src/Netclaw.Configuration.Tests/ExternalSkillsConfigTests.cs @@ -71,6 +71,30 @@ public void ClaudeCode_expands_every_installed_marketplace_with_a_skills_subdir( Assert.Contains(source.Paths, p => p.EndsWith(Path.Combine("marketplaces", "prose", "skills"), StringComparison.Ordinal)); } + [Fact] + public void ClaudeCode_marketplace_paths_are_sorted_for_stable_precedence() + { + Directory.CreateDirectory(ClaudeSkillsPath(_homeDir)); + Directory.CreateDirectory(MarketplaceSkillsPath(_homeDir, "zeta")); + Directory.CreateDirectory(MarketplaceSkillsPath(_homeDir, "alpha")); + + var config = new ExternalSkillsConfig + { + Sources = + { + new ExternalSkillSource { Name = "claude-code", WellKnown = "claude-code", Enabled = true } + } + }; + + var resolved = config.ResolveEnabledSources(_homeDir); + + var source = Assert.Single(resolved); + Assert.Equal(3, source.Paths.Count); + Assert.EndsWith(Path.Combine(".claude", "skills"), source.Paths[0]); + Assert.EndsWith(Path.Combine("marketplaces", "alpha", "skills"), source.Paths[1]); + Assert.EndsWith(Path.Combine("marketplaces", "zeta", "skills"), source.Paths[2]); + } + [Fact] public void ClaudeCode_skips_marketplaces_that_have_no_skills_subdir() { @@ -234,6 +258,18 @@ public void Probe_does_not_surface_commands_directory_as_a_skill_source() Assert.Empty(probed); } + [Fact] + public void Probe_detects_claude_code_when_only_marketplace_skills_exist() + { + Directory.CreateDirectory(MarketplaceSkillsPath(_homeDir, "dotnet-skills")); + + var probed = ExternalSkillsConfig.ProbeWellKnownSources(_homeDir); + + var result = Assert.Single(probed); + Assert.Equal("claude-code", result.WellKnownAlias); + Assert.EndsWith(Path.Combine("marketplaces", "dotnet-skills", "skills"), result.ResolvedPath); + } + [Fact] public void ResolveWellKnownPaths_returns_only_the_skills_path_for_claude_code() { diff --git a/src/Netclaw.Configuration/ExternalSkillsConfig.cs b/src/Netclaw.Configuration/ExternalSkillsConfig.cs index 32ef5182a..880ec6337 100644 --- a/src/Netclaw.Configuration/ExternalSkillsConfig.cs +++ b/src/Netclaw.Configuration/ExternalSkillsConfig.cs @@ -87,6 +87,7 @@ internal IReadOnlyList ResolveEnabledSources(string home } var existingPaths = new List(); + var seenPaths = new HashSet(GetPathComparer()); foreach (var candidate in candidatePaths) { if (string.IsNullOrWhiteSpace(candidate)) @@ -96,7 +97,8 @@ internal IReadOnlyList ResolveEnabledSources(string home if (!Directory.Exists(fullPath)) continue; - existingPaths.Add(fullPath); + if (seenPaths.Add(fullPath)) + existingPaths.Add(fullPath); } if (existingPaths.Count == 0) @@ -124,16 +126,14 @@ private static IReadOnlyList EnumerateClaudeCodeMarketplaceSkillPaths(st if (!Directory.Exists(marketplacesRoot)) return Array.Empty(); - try - { - return Directory.EnumerateDirectories(marketplacesRoot) - .Select(d => Path.Combine(d, "skills")) - .ToList(); - } - catch (UnauthorizedAccessException) - { - return Array.Empty(); - } + var pathComparer = GetPathComparer(); + + return Directory.EnumerateDirectories(marketplacesRoot) + .Select(d => Path.GetFullPath(Path.Combine(d, "skills"))) + .Where(Directory.Exists) + .Distinct(pathComparer) + .OrderBy(p => p, pathComparer) + .ToList(); } /// @@ -164,6 +164,13 @@ internal static IReadOnlyList ProbeWellKnownSources(string } } + if (firstExisting is null + && string.Equals(alias, ClaudeCodeAlias, StringComparison.Ordinal) + && EnumerateClaudeCodeMarketplaceSkillPaths(homeDirectory) is { Count: > 0 } marketplacePaths) + { + firstExisting = marketplacePaths[0]; + } + if (firstExisting is not null) results.Add(new WellKnownProbeResult(alias, displayName, firstExisting, allowSymlinks)); } @@ -201,6 +208,9 @@ internal static IReadOnlyList ResolveWellKnownPaths(string wellKnown, st return Array.Empty(); } + + private static StringComparer GetPathComparer() + => OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; } /// From 469b2bbfa9fa00186746180f506f6785b3f10198 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sat, 11 Apr 2026 16:08:42 +0000 Subject: [PATCH 4/4] fix(skills): restore claude commands compatibility --- .../Skills/SkillRegistryTests.cs | 3 +- .../Skills/SkillScannerTests.cs | 96 +++++++++++++++++ src/Netclaw.Actors/Skills/SkillScanner.cs | 101 +++++++++++++++++- .../ExternalSkillsConfigTests.cs | 37 ++++--- .../ExternalSkillsConfig.cs | 17 +-- .../Services/SkillDirectoryWatcherService.cs | 5 +- 6 files changed, 231 insertions(+), 28 deletions(-) diff --git a/src/Netclaw.Actors.Tests/Skills/SkillRegistryTests.cs b/src/Netclaw.Actors.Tests/Skills/SkillRegistryTests.cs index bbcb3880e..cf4665f6f 100644 --- a/src/Netclaw.Actors.Tests/Skills/SkillRegistryTests.cs +++ b/src/Netclaw.Actors.Tests/Skills/SkillRegistryTests.cs @@ -277,6 +277,7 @@ public void GenerateIndex_with_multi_path_external_source_joins_paths_with_semic new[] { "/home/user/.claude/skills", + "/home/user/.claude/commands", "/home/user/.claude/plugins/marketplaces/dotnet-skills/skills" }, true) @@ -285,7 +286,7 @@ public void GenerateIndex_with_multi_path_external_source_joins_paths_with_semic var index = registry.GenerateIndex("/home/user/.netclaw/skills", externalSources); Assert.Contains( - "claude-code=/home/user/.claude/skills;/home/user/.claude/plugins/marketplaces/dotnet-skills/skills", + "claude-code=/home/user/.claude/skills;/home/user/.claude/commands;/home/user/.claude/plugins/marketplaces/dotnet-skills/skills", index); } diff --git a/src/Netclaw.Actors.Tests/Skills/SkillScannerTests.cs b/src/Netclaw.Actors.Tests/Skills/SkillScannerTests.cs index 431394971..ea1505227 100644 --- a/src/Netclaw.Actors.Tests/Skills/SkillScannerTests.cs +++ b/src/Netclaw.Actors.Tests/Skills/SkillScannerTests.cs @@ -494,6 +494,102 @@ public void NonStrict_name_match_accepts_flat_file_with_mismatching_frontmatter_ Assert.DoesNotContain(result.Issues, i => i.Kind == SkillScanIssueKind.FrontmatterNameMismatch); } + [Fact] + public void Frontmatterless_flat_file_is_accepted_in_compatibility_mode() + { + File.WriteAllText(Path.Combine(_skillsDir, "review-pr.md"), + "Use the pr-review-specialist subagent to review pull requests."); + + var result = SkillScanner.Scan(_skillsDir, strictNameMatch: false, allowFrontmatterlessFlatFiles: true); + + var skill = Assert.Single(result.AcceptedSkills); + Assert.Equal("review-pr", skill.Name); + Assert.Equal("Review Pr", skill.DisplayName); + Assert.Equal("Use the pr-review-specialist subagent to review pull requests.", skill.Description); + Assert.True(skill.IsFlatFile); + } + + [Fact] + public void Frontmatterless_flat_file_is_rejected_without_compatibility_mode() + { + File.WriteAllText(Path.Combine(_skillsDir, "review-pr.md"), + "Use the pr-review-specialist subagent to review pull requests."); + + var result = SkillScanner.Scan(_skillsDir, strictNameMatch: false, allowFrontmatterlessFlatFiles: false); + + Assert.Empty(result.AcceptedSkills); + var issue = Assert.Single(result.Issues); + Assert.Equal(SkillScanIssueKind.FlatFileMissingFrontmatter, issue.Kind); + } + + [Fact] + public void Frontmatterless_flat_file_uses_heading_for_display_name() + { + File.WriteAllText(Path.Combine(_skillsDir, "release.md"), """ + # Create Release + + Use the release-manager subagent to run release steps. + """); + + var result = SkillScanner.Scan(_skillsDir, strictNameMatch: false, allowFrontmatterlessFlatFiles: true); + + var skill = Assert.Single(result.AcceptedSkills); + Assert.Equal("release", skill.Name); + Assert.Equal("Create Release", skill.DisplayName); + Assert.Equal("Create Release", skill.Description); + } + + [Fact] + public void Frontmatterless_empty_flat_file_is_rejected_with_no_description_issue() + { + File.WriteAllText(Path.Combine(_skillsDir, "empty.md"), " \n\n"); + + var result = SkillScanner.Scan(_skillsDir, strictNameMatch: false, allowFrontmatterlessFlatFiles: true); + + Assert.Empty(result.AcceptedSkills); + var issue = Assert.Single(result.Issues); + Assert.Equal(SkillScanIssueKind.FlatFileNoDescription, issue.Kind); + } + + [Fact] + public void ScanAndMerge_accepts_frontmatterless_files_for_claude_commands_path() + { + var commandsDir = Path.Combine(_skillsDir, ".claude", "commands"); + Directory.CreateDirectory(commandsDir); + File.WriteAllText(Path.Combine(commandsDir, "review-pr.md"), + "Use the pr-review-specialist subagent to review pull requests."); + + var merged = SkillScanner.ScanAndMerge( + nativeSkillsDirectory: _skillsDir, + externalSources: + [ + new ResolvedExternalSource("claude-code", [commandsDir], true) + ]); + + var skill = Assert.Single(merged.AcceptedSkills); + Assert.Equal("review-pr", skill.Name); + Assert.DoesNotContain(merged.Issues, i => i.Kind == SkillScanIssueKind.FlatFileMissingFrontmatter); + } + + [Fact] + public void ScanAndMerge_rejects_frontmatterless_files_for_non_commands_external_paths() + { + var externalDir = Path.Combine(_skillsDir, "team-skills"); + Directory.CreateDirectory(externalDir); + File.WriteAllText(Path.Combine(externalDir, "review-pr.md"), + "Use the pr-review-specialist subagent to review pull requests."); + + var merged = SkillScanner.ScanAndMerge( + nativeSkillsDirectory: _skillsDir, + externalSources: + [ + new ResolvedExternalSource("team", [externalDir], true) + ]); + + Assert.Empty(merged.AcceptedSkills); + Assert.Contains(merged.Issues, i => i.Kind == SkillScanIssueKind.FlatFileMissingFrontmatter); + } + [Fact] public void Symlinked_resource_tree_is_rejected_with_issue() { diff --git a/src/Netclaw.Actors/Skills/SkillScanner.cs b/src/Netclaw.Actors/Skills/SkillScanner.cs index 6ea6a8b7a..c22e777fc 100644 --- a/src/Netclaw.Actors/Skills/SkillScanner.cs +++ b/src/Netclaw.Actors/Skills/SkillScanner.cs @@ -52,7 +52,17 @@ public static partial class SkillScanner /// canonical — used for external sources like Claude Code where skill directory /// names don't have to align with the declared name. /// - public static SkillScanResult Scan(string skillsDirectory, bool allowSymlinks = false, bool strictNameMatch = true) + /// + /// When true, flat .md files without YAML frontmatter are treated + /// as valid skills with name derived from filename and description inferred from + /// first non-empty line. This compatibility mode is used for Claude Code + /// ~/.claude/commands files. + /// + public static SkillScanResult Scan( + string skillsDirectory, + bool allowSymlinks = false, + bool strictNameMatch = true, + bool allowFrontmatterlessFlatFiles = false) { if (!Directory.Exists(skillsDirectory)) return SkillScanResult.Empty; @@ -72,7 +82,7 @@ public static SkillScanResult Scan(string skillsDirectory, bool allowSymlinks = || fileName.StartsWith('.')) continue; - var entry = ParseFlatSkillFile(mdFile, rootFull, issues, allowSymlinks, strictNameMatch); + var entry = ParseFlatSkillFile(mdFile, rootFull, issues, allowSymlinks, strictNameMatch, allowFrontmatterlessFlatFiles); if (entry is not null) acceptedCandidates.Add(entry); } @@ -197,7 +207,11 @@ public static MergedSkillScanResult ScanAndMerge( { foreach (var path in source.Paths) { - var externalScan = Scan(path, allowSymlinks: source.AllowSymlinks, strictNameMatch: false); + var externalScan = Scan( + path, + allowSymlinks: source.AllowSymlinks, + strictNameMatch: false, + allowFrontmatterlessFlatFiles: IsClaudeCommandsDirectory(path)); allIssues.AddRange(externalScan.Issues); foreach (var skill in externalScan.AcceptedSkills) @@ -268,7 +282,13 @@ public static MergedSkillScanResult ScanAndMerge( /// files with valid YAML frontmatter — supported for compatibility with Claude Code /// and other platforms that allow skills without the directory wrapper. /// - private static SkillEntry? ParseFlatSkillFile(string filePath, string rootDirectory, List issues, bool allowSymlinks = false, bool strictNameMatch = true) + private static SkillEntry? ParseFlatSkillFile( + string filePath, + string rootDirectory, + List issues, + bool allowSymlinks = false, + bool strictNameMatch = true, + bool allowFrontmatterlessFlatFiles = false) { var canonicalRoot = NormalizeDirectoryPath(rootDirectory); var canonicalPath = ValidateCanonicalPath(filePath, canonicalRoot, issues, "flat skill file", allowSymlinks); @@ -292,6 +312,9 @@ public static MergedSkillScanResult ScanAndMerge( var frontmatter = ExtractFrontmatter(content); if (frontmatter is null) { + if (allowFrontmatterlessFlatFiles && !content.StartsWith("---", StringComparison.Ordinal)) + return BuildFlatSkillEntryWithoutFrontmatter(canonicalPath, canonicalRoot, content, issues); + issues.Add(new SkillScanIssue( Path: canonicalPath, Kind: SkillScanIssueKind.FlatFileMissingFrontmatter, @@ -526,6 +549,62 @@ private static string Truncate(string value, int maxLength) return value[..(maxLength - 3)] + "..."; } + private static SkillEntry? BuildFlatSkillEntryWithoutFrontmatter( + string canonicalPath, + string canonicalRoot, + string content, + List issues) + { + var description = ExtractFirstNonEmptyMarkdownLine(content); + if (string.IsNullOrWhiteSpace(description)) + { + issues.Add(new SkillScanIssue( + Path: canonicalPath, + Kind: SkillScanIssueKind.FlatFileNoDescription, + Message: "Flat .md file without frontmatter must contain at least one non-empty line to infer a description.")); + return null; + } + + var fileNameWithoutExt = Path.GetFileNameWithoutExtension(canonicalPath); + var name = NormalizeSkillName(fileNameWithoutExt); + + var headingMatch = HeadingRegex().Match(content); + var displayName = headingMatch.Success + ? headingMatch.Groups[1].Value.Trim() + : TitleCase(name); + + return new SkillEntry( + Name: name, + DisplayName: displayName, + Description: Truncate(description, MaxDescriptionLength), + FilePath: canonicalPath, + SkillDirectory: canonicalRoot, + Category: null) + { + ResourcePaths = null, + IsFlatFile = true + }; + } + + private static string? ExtractFirstNonEmptyMarkdownLine(string content) + { + var lines = content.Split('\n'); + foreach (var rawLine in lines) + { + var line = rawLine.Trim(); + if (line.Length == 0) + continue; + + if (line.StartsWith("#", StringComparison.Ordinal)) + line = line.TrimStart('#').Trim(); + + if (line.Length > 0) + return line; + } + + return null; + } + /// /// Only .system is scanned as a hidden directory (system skills from CDN). /// All other hidden directories are ignored. @@ -533,6 +612,20 @@ private static string Truncate(string value, int maxLength) private static bool IsAllowedHiddenDirectory(string dirName) => string.Equals(dirName, SystemCategory, StringComparison.Ordinal); + private static bool IsClaudeCommandsDirectory(string path) + { + var normalized = NormalizeDirectoryPath(path); + var directoryName = Path.GetFileName(normalized); + if (!string.Equals(directoryName, "commands", StringComparison.OrdinalIgnoreCase)) + return false; + + var parent = Path.GetDirectoryName(normalized); + if (string.IsNullOrEmpty(parent)) + return false; + + return string.Equals(Path.GetFileName(parent), ".claude", StringComparison.OrdinalIgnoreCase); + } + internal static string NormalizeSkillName(string value) => value.Trim().ToLowerInvariant(); diff --git a/src/Netclaw.Configuration.Tests/ExternalSkillsConfigTests.cs b/src/Netclaw.Configuration.Tests/ExternalSkillsConfigTests.cs index a53ec7e39..ce4b10e04 100644 --- a/src/Netclaw.Configuration.Tests/ExternalSkillsConfigTests.cs +++ b/src/Netclaw.Configuration.Tests/ExternalSkillsConfigTests.cs @@ -27,9 +27,10 @@ private static string MarketplaceSkillsPath(string home, string marketplace) => Path.Combine(home, ".claude", "plugins", "marketplaces", marketplace, "skills"); [Fact] - public void ClaudeCode_resolves_only_the_skills_path_when_no_marketplaces_installed() + public void ClaudeCode_resolves_skills_and_commands_paths_when_no_marketplaces_installed() { Directory.CreateDirectory(ClaudeSkillsPath(_homeDir)); + Directory.CreateDirectory(Path.Combine(_homeDir, ".claude", "commands")); var config = new ExternalSkillsConfig { @@ -43,14 +44,16 @@ public void ClaudeCode_resolves_only_the_skills_path_when_no_marketplaces_instal var source = Assert.Single(resolved); Assert.Equal("claude-code", source.Name); - Assert.Single(source.Paths); + Assert.Equal(2, source.Paths.Count); Assert.EndsWith(Path.Combine(".claude", "skills"), source.Paths[0]); + Assert.EndsWith(Path.Combine(".claude", "commands"), source.Paths[1]); } [Fact] public void ClaudeCode_expands_every_installed_marketplace_with_a_skills_subdir() { Directory.CreateDirectory(ClaudeSkillsPath(_homeDir)); + Directory.CreateDirectory(Path.Combine(_homeDir, ".claude", "commands")); Directory.CreateDirectory(MarketplaceSkillsPath(_homeDir, "dotnet-skills")); Directory.CreateDirectory(MarketplaceSkillsPath(_homeDir, "prose")); @@ -65,8 +68,9 @@ public void ClaudeCode_expands_every_installed_marketplace_with_a_skills_subdir( var resolved = config.ResolveEnabledSources(_homeDir); var source = Assert.Single(resolved); - Assert.Equal(3, source.Paths.Count); + Assert.Equal(4, source.Paths.Count); Assert.Contains(source.Paths, p => p.EndsWith(Path.Combine(".claude", "skills"), StringComparison.Ordinal)); + Assert.Contains(source.Paths, p => p.EndsWith(Path.Combine(".claude", "commands"), StringComparison.Ordinal)); Assert.Contains(source.Paths, p => p.EndsWith(Path.Combine("marketplaces", "dotnet-skills", "skills"), StringComparison.Ordinal)); Assert.Contains(source.Paths, p => p.EndsWith(Path.Combine("marketplaces", "prose", "skills"), StringComparison.Ordinal)); } @@ -75,6 +79,7 @@ public void ClaudeCode_expands_every_installed_marketplace_with_a_skills_subdir( public void ClaudeCode_marketplace_paths_are_sorted_for_stable_precedence() { Directory.CreateDirectory(ClaudeSkillsPath(_homeDir)); + Directory.CreateDirectory(Path.Combine(_homeDir, ".claude", "commands")); Directory.CreateDirectory(MarketplaceSkillsPath(_homeDir, "zeta")); Directory.CreateDirectory(MarketplaceSkillsPath(_homeDir, "alpha")); @@ -89,16 +94,18 @@ public void ClaudeCode_marketplace_paths_are_sorted_for_stable_precedence() var resolved = config.ResolveEnabledSources(_homeDir); var source = Assert.Single(resolved); - Assert.Equal(3, source.Paths.Count); + Assert.Equal(4, source.Paths.Count); Assert.EndsWith(Path.Combine(".claude", "skills"), source.Paths[0]); - Assert.EndsWith(Path.Combine("marketplaces", "alpha", "skills"), source.Paths[1]); - Assert.EndsWith(Path.Combine("marketplaces", "zeta", "skills"), source.Paths[2]); + Assert.EndsWith(Path.Combine(".claude", "commands"), source.Paths[1]); + Assert.EndsWith(Path.Combine("marketplaces", "alpha", "skills"), source.Paths[2]); + Assert.EndsWith(Path.Combine("marketplaces", "zeta", "skills"), source.Paths[3]); } [Fact] public void ClaudeCode_skips_marketplaces_that_have_no_skills_subdir() { Directory.CreateDirectory(ClaudeSkillsPath(_homeDir)); + Directory.CreateDirectory(Path.Combine(_homeDir, ".claude", "commands")); Directory.CreateDirectory(MarketplaceSkillsPath(_homeDir, "dotnet-skills")); Directory.CreateDirectory(Path.Combine(MarketplacesRoot(_homeDir), "empty-marketplace")); @@ -113,8 +120,9 @@ public void ClaudeCode_skips_marketplaces_that_have_no_skills_subdir() var resolved = config.ResolveEnabledSources(_homeDir); var source = Assert.Single(resolved); - Assert.Equal(2, source.Paths.Count); + Assert.Equal(3, source.Paths.Count); Assert.Contains(source.Paths, p => p.EndsWith(Path.Combine(".claude", "skills"), StringComparison.Ordinal)); + Assert.Contains(source.Paths, p => p.EndsWith(Path.Combine(".claude", "commands"), StringComparison.Ordinal)); Assert.Contains(source.Paths, p => p.EndsWith(Path.Combine("marketplaces", "dotnet-skills", "skills"), StringComparison.Ordinal)); Assert.DoesNotContain(source.Paths, p => p.Contains("empty-marketplace", StringComparison.Ordinal)); } @@ -246,16 +254,16 @@ public void Probe_returns_no_results_when_no_paths_exist() } [Fact] - public void Probe_does_not_surface_commands_directory_as_a_skill_source() + public void Probe_surfaces_commands_directory_for_claude_code() { - // Claude Code's flat slash-command files live at ~/.claude/commands. They - // use a different frontmatter schema and must not be reported as a - // valid claude-code source even when the skills/ dir is absent. + // Claude Code now treats ~/.claude/commands markdown files as skills. Directory.CreateDirectory(Path.Combine(_homeDir, ".claude", "commands")); var probed = ExternalSkillsConfig.ProbeWellKnownSources(_homeDir); - Assert.Empty(probed); + var result = Assert.Single(probed); + Assert.Equal("claude-code", result.WellKnownAlias); + Assert.EndsWith(Path.Combine(".claude", "commands"), result.ResolvedPath); } [Fact] @@ -271,12 +279,13 @@ public void Probe_detects_claude_code_when_only_marketplace_skills_exist() } [Fact] - public void ResolveWellKnownPaths_returns_only_the_skills_path_for_claude_code() + public void ResolveWellKnownPaths_returns_skills_and_commands_paths_for_claude_code() { var paths = ExternalSkillsConfig.ResolveWellKnownPaths("claude-code", _homeDir); - Assert.Single(paths); + Assert.Equal(2, paths.Count); Assert.EndsWith(Path.Combine(".claude", "skills"), paths[0]); + Assert.EndsWith(Path.Combine(".claude", "commands"), paths[1]); } [Fact] diff --git a/src/Netclaw.Configuration/ExternalSkillsConfig.cs b/src/Netclaw.Configuration/ExternalSkillsConfig.cs index 880ec6337..35d3f4b64 100644 --- a/src/Netclaw.Configuration/ExternalSkillsConfig.cs +++ b/src/Netclaw.Configuration/ExternalSkillsConfig.cs @@ -14,17 +14,20 @@ public sealed class ExternalSkillsConfig /// primary (used for display/validation); all existing paths are scanned. /// /// - /// The claude-code alias is also expanded at resolution time by - /// to include every installed - /// plugin marketplace under ~/.claude/plugins/marketplaces/*/skills/, - /// so marketplace skills (e.g. the dotnet-skills plugin) show up without - /// needing a separate configured source. That expansion is dynamic and - /// lives outside the static catalog. + /// The claude-code alias includes both ~/.claude/skills/ and + /// ~/.claude/commands/. Claude Code treats command markdown files as + /// skills, so Netclaw must scan both locations. The alias is also expanded + /// at resolution time by to + /// include every installed plugin marketplace under + /// ~/.claude/plugins/marketplaces/*/skills/, so marketplace skills + /// (e.g. the dotnet-skills plugin) show up without needing a separate + /// configured source. That marketplace expansion is dynamic and lives + /// outside the static catalog. /// private static readonly (string Alias, string DisplayName, string[] RelativePaths, bool DefaultAllowSymlinks)[] WellKnownCatalog = [ (ClaudeCodeAlias, "Claude Code", - new[] { Path.Combine(".claude", "skills") }, + new[] { Path.Combine(".claude", "skills"), Path.Combine(".claude", "commands") }, true), ("open-code", "Open Code", new[] { Path.Combine(".open-code", "skills") }, diff --git a/src/Netclaw.Daemon/Services/SkillDirectoryWatcherService.cs b/src/Netclaw.Daemon/Services/SkillDirectoryWatcherService.cs index 9929e06ff..0caa18152 100644 --- a/src/Netclaw.Daemon/Services/SkillDirectoryWatcherService.cs +++ b/src/Netclaw.Daemon/Services/SkillDirectoryWatcherService.cs @@ -47,8 +47,9 @@ protected override Task ExecuteAsync(CancellationToken stoppingToken) TryCreateWatcher(_paths.SkillsDirectory, "native"); // Watch each external source directory. A single source may cover multiple - // paths (e.g. claude-code = ~/.claude/skills plus one path per installed - // plugin marketplace under ~/.claude/plugins/marketplaces/*/skills/). + // paths (e.g. claude-code = ~/.claude/skills + ~/.claude/commands + one + // path per installed plugin marketplace under + // ~/.claude/plugins/marketplaces/*/skills/). foreach (var source in _externalSources) { foreach (var path in source.Paths)