diff --git a/src/Netclaw.Actors.Tests/Skills/SkillRegistryTests.cs b/src/Netclaw.Actors.Tests/Skills/SkillRegistryTests.cs
index 8b43b4929..cf4665f6f 100644
--- a/src/Netclaw.Actors.Tests/Skills/SkillRegistryTests.cs
+++ b/src/Netclaw.Actors.Tests/Skills/SkillRegistryTests.cs
@@ -274,13 +274,20 @@ 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/commands",
+ "/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/commands;/home/user/.claude/plugins/marketplaces/dotnet-skills/skills",
+ index);
}
[Fact]
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 b3b0585ae..ce4b10e04 100644
--- a/src/Netclaw.Configuration.Tests/ExternalSkillsConfigTests.cs
+++ b/src/Netclaw.Configuration.Tests/ExternalSkillsConfigTests.cs
@@ -19,13 +19,18 @@ 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_skills_and_commands_paths_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));
+ Directory.CreateDirectory(Path.Combine(_homeDir, ".claude", "commands"));
var config = new ExternalSkillsConfig
{
@@ -40,14 +45,43 @@ 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.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"));
+
+ 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(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));
}
[Fact]
- public void ClaudeCode_drops_missing_commands_path_silently()
+ public void ClaudeCode_marketplace_paths_are_sorted_for_stable_precedence()
{
- Directory.CreateDirectory(Path.Combine(_homeDir, ".claude", "skills"));
+ Directory.CreateDirectory(ClaudeSkillsPath(_homeDir));
+ Directory.CreateDirectory(Path.Combine(_homeDir, ".claude", "commands"));
+ Directory.CreateDirectory(MarketplaceSkillsPath(_homeDir, "zeta"));
+ Directory.CreateDirectory(MarketplaceSkillsPath(_homeDir, "alpha"));
var config = new ExternalSkillsConfig
{
@@ -60,14 +94,45 @@ public void ClaudeCode_drops_missing_commands_path_silently()
var resolved = config.ResolveEnabledSources(_homeDir);
var source = Assert.Single(resolved);
- Assert.Single(source.Paths);
+ Assert.Equal(4, source.Paths.Count);
Assert.EndsWith(Path.Combine(".claude", "skills"), source.Paths[0]);
+ 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_resolves_when_only_commands_dir_exists()
+ 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"));
+
+ 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(".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_resolves_marketplace_only_when_primary_skills_dir_is_missing()
+ {
+ // 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
{
@@ -81,13 +146,79 @@ 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("marketplaces", "dotnet-skills", "skills"), source.Paths[0]);
+ }
+
+ [Fact]
+ public void ClaudeCode_does_not_crash_when_marketplaces_root_is_missing()
+ {
+ Directory.CreateDirectory(ClaudeSkillsPath(_homeDir));
+
+ 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.Single(source.Paths);
+ 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 +236,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,18 +254,32 @@ 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_surfaces_commands_directory_for_claude_code()
{
+ // Claude Code now treats ~/.claude/commands markdown files as skills.
Directory.CreateDirectory(Path.Combine(_homeDir, ".claude", "commands"));
var probed = ExternalSkillsConfig.ProbeWellKnownSources(_homeDir);
var result = Assert.Single(probed);
+ Assert.Equal("claude-code", result.WellKnownAlias);
Assert.EndsWith(Path.Combine(".claude", "commands"), result.ResolvedPath);
}
[Fact]
- public void ResolveWellKnownPaths_returns_all_paths_for_claude_code()
+ 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_skills_and_commands_paths_for_claude_code()
{
var paths = ExternalSkillsConfig.ResolveWellKnownPaths("claude-code", _homeDir);
diff --git a/src/Netclaw.Configuration/ExternalSkillsConfig.cs b/src/Netclaw.Configuration/ExternalSkillsConfig.cs
index a4c08e473..35d3f4b64 100644
--- a/src/Netclaw.Configuration/ExternalSkillsConfig.cs
+++ b/src/Netclaw.Configuration/ExternalSkillsConfig.cs
@@ -10,15 +10,23 @@ 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 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 =
[
- ("claude-code", "Claude Code",
+ (ClaudeCodeAlias, "Claude Code",
new[] { Path.Combine(".claude", "skills"), Path.Combine(".claude", "commands") },
true),
("open-code", "Open Code",
@@ -26,6 +34,21 @@ private static readonly (string Alias, string DisplayName, string[] RelativePath
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,7 +82,15 @@ 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();
+ var seenPaths = new HashSet(GetPathComparer());
foreach (var candidate in candidatePaths)
{
if (string.IsNullOrWhiteSpace(candidate))
@@ -69,7 +100,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)
@@ -81,6 +113,32 @@ internal IReadOnlyList ResolveEnabledSources(string home
return results;
}
+ ///
+ /// 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)
+ {
+ var marketplacesRoot = Path.Combine(homeDirectory, ClaudeCodeMarketplacesRelativePath);
+ if (!Directory.Exists(marketplacesRoot))
+ 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();
+ }
+
///
/// Probes the filesystem for well-known external skill directories and returns
/// those where at least one configured path exists on disk.
@@ -109,6 +167,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));
}
@@ -146,6 +211,9 @@ internal static IReadOnlyList ResolveWellKnownPaths(string wellKnown, st
return Array.Empty();
}
+
+ private static StringComparer GetPathComparer()
+ => OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
}
///
diff --git a/src/Netclaw.Daemon/Services/SkillDirectoryWatcherService.cs b/src/Netclaw.Daemon/Services/SkillDirectoryWatcherService.cs
index a3919d4a4..0caa18152 100644
--- a/src/Netclaw.Daemon/Services/SkillDirectoryWatcherService.cs
+++ b/src/Netclaw.Daemon/Services/SkillDirectoryWatcherService.cs
@@ -47,7 +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 + ~/.claude/commands).
+ // 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)