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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions src/Netclaw.Actors.Tests/Skills/SkillRegistryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
96 changes: 96 additions & 0 deletions src/Netclaw.Actors.Tests/Skills/SkillScannerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
101 changes: 97 additions & 4 deletions src/Netclaw.Actors/Skills/SkillScanner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </param>
public static SkillScanResult Scan(string skillsDirectory, bool allowSymlinks = false, bool strictNameMatch = true)
/// <param name="allowFrontmatterlessFlatFiles">
/// When <c>true</c>, flat <c>.md</c> 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
/// <c>~/.claude/commands</c> files.
/// </param>
public static SkillScanResult Scan(
string skillsDirectory,
bool allowSymlinks = false,
bool strictNameMatch = true,
bool allowFrontmatterlessFlatFiles = false)
{
if (!Directory.Exists(skillsDirectory))
return SkillScanResult.Empty;
Expand All @@ -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);
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
/// </summary>
private static SkillEntry? ParseFlatSkillFile(string filePath, string rootDirectory, List<SkillScanIssue> issues, bool allowSymlinks = false, bool strictNameMatch = true)
private static SkillEntry? ParseFlatSkillFile(
string filePath,
string rootDirectory,
List<SkillScanIssue> issues,
bool allowSymlinks = false,
bool strictNameMatch = true,
bool allowFrontmatterlessFlatFiles = false)
{
var canonicalRoot = NormalizeDirectoryPath(rootDirectory);
var canonicalPath = ValidateCanonicalPath(filePath, canonicalRoot, issues, "flat skill file", allowSymlinks);
Expand All @@ -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,
Expand Down Expand Up @@ -526,13 +549,83 @@ private static string Truncate(string value, int maxLength)
return value[..(maxLength - 3)] + "...";
}

private static SkillEntry? BuildFlatSkillEntryWithoutFrontmatter(
string canonicalPath,
string canonicalRoot,
string content,
List<SkillScanIssue> 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;
}

/// <summary>
/// Only <c>.system</c> is scanned as a hidden directory (system skills from CDN).
/// All other hidden directories are ignored.
/// </summary>
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();

Expand Down
Loading