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)