diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs index 79595f17a2..972dbee53f 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs @@ -32,15 +32,15 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource private const string SkillFileName = "SKILL.md"; private const int MaxSearchDepth = 2; - // "." means the skill directory root itself (no sub-folder descent constraint) - private const string RootFolderIndicator = "."; + // "." means the skill directory root itself (no subdirectory descent constraint) + private const string RootDirectoryIndicator = "."; private static readonly string[] s_defaultScriptExtensions = [".py", ".js", ".sh", ".ps1", ".cs", ".csx"]; private static readonly string[] s_defaultResourceExtensions = [".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt"]; - // Standard sub-folder names per https://agentskills.io/specification#directory-structure - private static readonly string[] s_defaultScriptFolders = ["scripts"]; - private static readonly string[] s_defaultResourceFolders = ["references", "assets"]; + // Standard subdirectory names per https://agentskills.io/specification#directory-structure + private static readonly string[] s_defaultScriptDirectories = ["scripts"]; + private static readonly string[] s_defaultResourceDirectories = ["references", "assets"]; // Matches YAML frontmatter delimited by "---" lines. Group 1 = content between delimiters. // Multiline makes ^/$ match line boundaries; Singleline makes . match newlines across the block. @@ -63,8 +63,8 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource private readonly IEnumerable _skillPaths; private readonly HashSet _allowedResourceExtensions; private readonly HashSet _allowedScriptExtensions; - private readonly IReadOnlyList _scriptFolders; - private readonly IReadOnlyList _resourceFolders; + private readonly IReadOnlyList _scriptDirectories; + private readonly IReadOnlyList _resourceDirectories; private readonly AgentFileSkillScriptRunner? _scriptRunner; private readonly ILogger _logger; @@ -111,13 +111,13 @@ public AgentFileSkillsSource( options?.AllowedScriptExtensions ?? s_defaultScriptExtensions, StringComparer.OrdinalIgnoreCase); - this._scriptFolders = options?.ScriptFolders is not null - ? [.. ValidateAndNormalizeFolderNames(options.ScriptFolders, this._logger)] - : s_defaultScriptFolders; + this._scriptDirectories = options?.ScriptDirectories is not null + ? [.. ValidateAndNormalizeDirectoryNames(options.ScriptDirectories, this._logger)] + : s_defaultScriptDirectories; - this._resourceFolders = options?.ResourceFolders is not null - ? [.. ValidateAndNormalizeFolderNames(options.ResourceFolders, this._logger)] - : s_defaultResourceFolders; + this._resourceDirectories = options?.ResourceDirectories is not null + ? [.. ValidateAndNormalizeDirectoryNames(options.ResourceDirectories, this._logger)] + : s_defaultResourceDirectories; this._scriptRunner = scriptRunner; } @@ -303,12 +303,12 @@ private bool TryParseFrontmatter(string content, string skillFilePath, [NotNullW } /// - /// Scans configured resource folders within a skill directory for resource files matching the configured extensions. + /// Scans configured resource directories within a skill directory for resource files matching the configured extensions. /// /// - /// By default, scans references/ and assets/ sub-folders as specified by the + /// By default, scans references/ and assets/ subdirectories as specified by the /// Agent Skills specification. - /// Configure to scan different or + /// Configure to scan different or /// additional directories, including "." for the skill root itself. /// Each file is validated against path-traversal and symlink-escape checks; unsafe files are skipped. /// @@ -316,14 +316,14 @@ private List DiscoverResourceFiles(string skillDirectory { var resources = new List(); - foreach (string folder in this._resourceFolders.Distinct(StringComparer.OrdinalIgnoreCase)) + foreach (string directory in this._resourceDirectories.Distinct(StringComparer.OrdinalIgnoreCase)) { - bool isRootFolder = string.Equals(folder, RootFolderIndicator, StringComparison.Ordinal); + bool isRootDirectory = string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal); // GetFullPath normalizes mixed separators (e.g. "C:\skill\scripts/f1" → "C:\skill\scripts\f1") - string targetDirectory = isRootFolder + string targetDirectory = isRootDirectory ? skillDirectoryFullPath - : Path.GetFullPath(Path.Combine(skillDirectoryFullPath, folder)) + Path.DirectorySeparatorChar; + : Path.GetFullPath(Path.Combine(skillDirectoryFullPath, directory)) + Path.DirectorySeparatorChar; if (!Directory.Exists(targetDirectory)) { @@ -331,13 +331,13 @@ private List DiscoverResourceFiles(string skillDirectory } // Directory-level symlink check: skip if targetDirectory (or any intermediate - // segment) is a reparse point. The root folder is excluded — it's a caller-supplied + // segment) is a reparse point. The root directory is excluded — it's a caller-supplied // trusted path, and the security boundary guards files within it, not the path itself. - if (!isRootFolder && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath)) + if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath)) { if (this._logger.IsEnabled(LogLevel.Warning)) { - LogResourceSymlinkFolder(this._logger, skillName, SanitizePathForLog(folder)); + LogResourceSymlinkDirectory(this._logger, skillName, SanitizePathForLog(directory)); } continue; @@ -380,7 +380,7 @@ private List DiscoverResourceFiles(string skillDirectory // e.g. "references/../../../etc/shadow" → "/etc/shadow" string resolvedFilePath = Path.GetFullPath(filePath); - // Path containment: reject if the resolved path escapes the target folder. + // Path containment: reject if the resolved path escapes the target directory. // e.g. "/etc/shadow".StartsWith("/skills/myskill/references/") → false → skip if (!resolvedFilePath.StartsWith(targetDirectory, StringComparison.OrdinalIgnoreCase)) { @@ -416,12 +416,12 @@ private List DiscoverResourceFiles(string skillDirectory } /// - /// Scans configured script folders within a skill directory for script files matching the configured extensions. + /// Scans configured script directories within a skill directory for script files matching the configured extensions. /// /// - /// By default, scans the scripts/ sub-folder as specified by the + /// By default, scans the scripts/ subdirectory as specified by the /// Agent Skills specification. - /// Configure to scan different or + /// Configure to scan different or /// additional directories, including "." for the skill root itself. /// Each file is validated against path-traversal and symlink-escape checks; unsafe files are skipped. /// @@ -429,14 +429,14 @@ private List DiscoverScriptFiles(string skillDirectoryFull { var scripts = new List(); - foreach (string folder in this._scriptFolders.Distinct(StringComparer.OrdinalIgnoreCase)) + foreach (string directory in this._scriptDirectories.Distinct(StringComparer.OrdinalIgnoreCase)) { - bool isRootFolder = string.Equals(folder, RootFolderIndicator, StringComparison.Ordinal); + bool isRootDirectory = string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal); // GetFullPath normalizes mixed separators (e.g. "C:\skill\scripts/f1" → "C:\skill\scripts\f1") - string targetDirectory = isRootFolder + string targetDirectory = isRootDirectory ? skillDirectoryFullPath - : Path.GetFullPath(Path.Combine(skillDirectoryFullPath, folder)) + Path.DirectorySeparatorChar; + : Path.GetFullPath(Path.Combine(skillDirectoryFullPath, directory)) + Path.DirectorySeparatorChar; if (!Directory.Exists(targetDirectory)) { @@ -444,13 +444,13 @@ private List DiscoverScriptFiles(string skillDirectoryFull } // Directory-level symlink check: skip if targetDirectory (or any intermediate - // segment) is a reparse point. The root folder is excluded — it's a caller-supplied + // segment) is a reparse point. The root directory is excluded — it's a caller-supplied // trusted path, and the security boundary guards files within it, not the path itself. - if (!isRootFolder && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath)) + if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath)) { if (this._logger.IsEnabled(LogLevel.Warning)) { - LogScriptSymlinkFolder(this._logger, skillName, SanitizePathForLog(folder)); + LogScriptSymlinkDirectory(this._logger, skillName, SanitizePathForLog(directory)); } continue; @@ -480,7 +480,7 @@ private List DiscoverScriptFiles(string skillDirectoryFull // e.g. "scripts/../../../etc/shadow" → "/etc/shadow" string resolvedFilePath = Path.GetFullPath(filePath); - // Path containment: reject if the resolved path escapes the target folder. + // Path containment: reject if the resolved path escapes the target directory. // e.g. "/etc/shadow".StartsWith("/skills/myskill/scripts/") → false → skip if (!resolvedFilePath.StartsWith(targetDirectory, StringComparison.OrdinalIgnoreCase)) { @@ -541,8 +541,8 @@ private static bool HasSymlinkInPath(string pathToCheck, string trustedBasePath) } /// - /// Normalizes a relative path or folder name by stripping a leading "./"/".\", - /// trimming trailing directory separators, and replacing backslashes with forward + /// Normalizes a relative path or directory name by stripping a leading "./"/".\", + /// trimming trailing separators, and replacing backslashes with forward /// slashes. /// private static string NormalizePath(string path) @@ -602,36 +602,36 @@ private static void ValidateExtensions(IEnumerable? extensions) } } - private static IEnumerable ValidateAndNormalizeFolderNames(IEnumerable folders, ILogger logger) + private static IEnumerable ValidateAndNormalizeDirectoryNames(IEnumerable directories, ILogger logger) { - foreach (string folder in folders) + foreach (string directory in directories) { - if (string.IsNullOrWhiteSpace(folder)) + if (string.IsNullOrWhiteSpace(directory)) { - throw new ArgumentException("Folder names must not be null or whitespace.", nameof(folders)); + throw new ArgumentException("Directory names must not be null or whitespace.", nameof(directories)); } // "." is valid — it means the skill root directory. - if (string.Equals(folder, RootFolderIndicator, StringComparison.Ordinal)) + if (string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal)) { - yield return folder; + yield return directory; continue; } // Reject absolute paths and any path segments that escape upward. - if (Path.IsPathRooted(folder) || ContainsParentTraversalSegment(folder)) + if (Path.IsPathRooted(directory) || ContainsParentTraversalSegment(directory)) { - LogFolderNameSkippedInvalid(logger, folder); + LogDirectoryNameSkippedInvalid(logger, directory); continue; } - yield return NormalizePath(folder); + yield return NormalizePath(directory); } } - private static bool ContainsParentTraversalSegment(string folder) + private static bool ContainsParentTraversalSegment(string directory) { - foreach (string segment in folder.Split('/', '\\')) + foreach (string segment in directory.Split('/', '\\')) { if (segment == "..") { @@ -666,8 +666,8 @@ private static bool ContainsParentTraversalSegment(string folder) [LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' is a symlink that resolves outside the skill directory")] private static partial void LogResourceSymlinkEscape(ILogger logger, string skillName, string resourcePath); - [LoggerMessage(LogLevel.Warning, "Skipping resource folder '{FolderName}' in skill '{SkillName}': folder path contains a symlink")] - private static partial void LogResourceSymlinkFolder(ILogger logger, string skillName, string folderName); + [LoggerMessage(LogLevel.Warning, "Skipping resource directory '{DirectoryName}' in skill '{SkillName}': directory path contains a symlink")] + private static partial void LogResourceSymlinkDirectory(ILogger logger, string skillName, string directoryName); [LoggerMessage(LogLevel.Debug, "Skipping file '{FilePath}' in skill '{SkillName}': extension '{Extension}' is not in the allowed list")] private static partial void LogResourceSkippedExtension(ILogger logger, string skillName, string filePath, string extension); @@ -678,9 +678,9 @@ private static bool ContainsParentTraversalSegment(string folder) [LoggerMessage(LogLevel.Warning, "Skipping script in skill '{SkillName}': '{ScriptPath}' is a symlink that resolves outside the skill directory")] private static partial void LogScriptSymlinkEscape(ILogger logger, string skillName, string scriptPath); - [LoggerMessage(LogLevel.Warning, "Skipping script folder '{FolderName}' in skill '{SkillName}': folder path contains a symlink")] - private static partial void LogScriptSymlinkFolder(ILogger logger, string skillName, string folderName); + [LoggerMessage(LogLevel.Warning, "Skipping script directory '{DirectoryName}' in skill '{SkillName}': directory path contains a symlink")] + private static partial void LogScriptSymlinkDirectory(ILogger logger, string skillName, string directoryName); - [LoggerMessage(LogLevel.Warning, "Skipping invalid folder name '{FolderName}': must be a relative path with no '..' segments")] - private static partial void LogFolderNameSkippedInvalid(ILogger logger, string folderName); + [LoggerMessage(LogLevel.Warning, "Skipping invalid directory name '{DirectoryName}': must be a relative path with no '..' segments")] + private static partial void LogDirectoryNameSkippedInvalid(ILogger logger, string directoryName); } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs index fcd9398104..b5c83c0220 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs @@ -32,7 +32,7 @@ public sealed class AgentFileSkillsSourceOptions public IEnumerable? AllowedScriptExtensions { get; set; } /// - /// Gets or sets relative folder paths to scan for script files within each skill directory. + /// Gets or sets relative directory paths to scan for script files within each skill directory. /// Values may be single-segment names (e.g., "scripts") or multi-segment relative /// paths (e.g., "sub/scripts"). Use "." to include files directly at the /// skill root. Leading "./" prefixes, trailing separators, and backslashes are @@ -42,10 +42,10 @@ public sealed class AgentFileSkillsSourceOptions /// Agent Skills specification). /// When set, replaces the defaults entirely. /// - public IEnumerable? ScriptFolders { get; set; } + public IEnumerable? ScriptDirectories { get; set; } /// - /// Gets or sets relative folder paths to scan for resource files within each skill directory. + /// Gets or sets relative directory paths to scan for resource files within each skill directory. /// Values may be single-segment names (e.g., "references") or multi-segment relative /// paths (e.g., "sub/resources"). Use "." to include files directly at the /// skill root. Leading "./" prefixes, trailing separators, and backslashes are @@ -55,5 +55,5 @@ public sealed class AgentFileSkillsSourceOptions /// Agent Skills specification). /// When set, replaces the defaults entirely. /// - public IEnumerable? ResourceFolders { get; set; } + public IEnumerable? ResourceDirectories { get; set; } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs index d524b6142a..f1ca662202 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs @@ -116,8 +116,8 @@ public async Task GetSkillsAsync_NoScriptFiles_ReturnsEmptyScriptsAsync() [Fact] public async Task GetSkillsAsync_ScriptsOutsideScriptsDir_AreNotDiscoveredAsync() { - // Arrange — scripts outside configured folders are not discovered; only files directly - // inside the configured folder are picked up (no subdirectory recursion) + // Arrange — scripts outside configured directories are not discovered; only files directly + // inside the configured directory are picked up (no subdirectory recursion) string skillDir = CreateSkillDir(this._testRoot, "root-scripts", "Root scripts skill", "Body."); CreateFile(skillDir, "convert.py", "print('root')"); CreateFile(skillDir, "tools/helper.sh", "echo 'helper'"); @@ -126,7 +126,7 @@ public async Task GetSkillsAsync_ScriptsOutsideScriptsDir_AreNotDiscoveredAsync( // Act var skills = await source.GetSkillsAsync(CancellationToken.None); - // Assert — neither file is in the default scripts/ folder, so no scripts are discovered + // Assert — neither file is in the default scripts/ directory, so no scripts are discovered Assert.Single(skills); Assert.Empty(skills[0].Scripts!); } @@ -229,18 +229,18 @@ public async Task GetSkillsAsync_ExecutorReceivesArgumentsAsync() } [Fact] - public async Task GetSkillsAsync_ScriptFoldersWithNestedPath_DiscoversScriptsAsync() + public async Task GetSkillsAsync_ScriptDirectoriesWithNestedPath_DiscoversScriptsAsync() { - // Arrange — ScriptFolders configured with a multi-segment relative path (f1/f2/f3) - string skillDir = CreateSkillDir(this._testRoot, "nested-script-skill", "Nested script folder", "Body."); + // Arrange — ScriptDirectories configured with a multi-segment relative path (f1/f2/f3) + string skillDir = CreateSkillDir(this._testRoot, "nested-script-skill", "Nested script directory", "Body."); CreateFile(skillDir, "f1/f2/f3/run.py", "print('nested')"); var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, - new AgentFileSkillsSourceOptions { ScriptFolders = ["f1/f2/f3"] }); + new AgentFileSkillsSourceOptions { ScriptDirectories = ["f1/f2/f3"] }); // Act var skills = await source.GetSkillsAsync(CancellationToken.None); - // Assert — script file inside the deeply nested folder is discovered + // Assert — script file inside the deeply nested directory is discovered Assert.Single(skills); Assert.Single(skills[0].Scripts!); Assert.Equal("f1/f2/f3/run.py", skills[0].Scripts![0].Name); @@ -250,29 +250,29 @@ public async Task GetSkillsAsync_ScriptFoldersWithNestedPath_DiscoversScriptsAsy [InlineData("./scripts")] [InlineData("./scripts/f1")] [InlineData("./scripts/f1", "./f2")] - public async Task GetSkillsAsync_ScriptFolderWithDotSlashPrefix_DiscoversScriptsAsync(params string[] folders) + public async Task GetSkillsAsync_ScriptDirectoryWithDotSlashPrefix_DiscoversScriptsAsync(params string[] directories) { - // Arrange — "./"-prefixed folders are equivalent to their counterparts without the prefix; + // Arrange — "./"-prefixed directories are equivalent to their counterparts without the prefix; // the leading "./" is transparently normalized by Path.GetFullPath during file enumeration. string skillDir = CreateSkillDir(this._testRoot, "dotslash-script-skill", "Dot-slash prefix", "Body."); - foreach (string folder in folders) + foreach (string directory in directories) { - string folderWithoutDotSlash = folder.Substring(2); // strip "./" - CreateFile(skillDir, $"{folderWithoutDotSlash}/run.py", "print('dotslash')"); + string directoryWithoutDotSlash = directory.Substring(2); // strip "./" + CreateFile(skillDir, $"{directoryWithoutDotSlash}/run.py", "print('dotslash')"); } var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, - new AgentFileSkillsSourceOptions { ScriptFolders = folders }); + new AgentFileSkillsSourceOptions { ScriptDirectories = directories }); // Act var skills = await source.GetSkillsAsync(CancellationToken.None); - // Assert — scripts are discovered with names identical to using folders without "./" + // Assert — scripts are discovered with names identical to using directories without "./" Assert.Single(skills); - Assert.Equal(folders.Length, skills[0].Scripts!.Count); - foreach (string folder in folders) + Assert.Equal(directories.Length, skills[0].Scripts!.Count); + foreach (string directory in directories) { - string expectedName = $"{folder.Substring(2)}/run.py"; + string expectedName = $"{directory.Substring(2)}/run.py"; Assert.Contains(skills[0].Scripts!, s => s.Name == expectedName); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs index ca0884ea43..c43568acd9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs @@ -199,7 +199,7 @@ public async Task GetSkillsAsync_NameMismatchesDirectory_ExcludesSkillAsync() [Fact] public async Task GetSkillsAsync_FilesWithMatchingExtensions_DiscoveredAsResourcesAsync() { - // Arrange — create resource files in spec-defined sub-folders + // Arrange — create resource files in spec-defined subdirectories string skillDir = Path.Combine(this._testRoot, "resource-skill"); string refsDir = Path.Combine(skillDir, "references"); string assetsDir = Path.Combine(skillDir, "assets"); @@ -226,7 +226,7 @@ public async Task GetSkillsAsync_FilesWithMatchingExtensions_DiscoveredAsResourc [Fact] public async Task GetSkillsAsync_FilesWithNonMatchingExtensions_NotDiscoveredAsync() { - // Arrange — create a file with an extension not in the default list inside a spec folder + // Arrange — create a file with an extension not in the default list inside a spec directory string skillDir = Path.Combine(this._testRoot, "ext-skill"); string refsDir = Path.Combine(skillDir, "references"); Directory.CreateDirectory(refsDir); @@ -300,7 +300,7 @@ public async Task GetSkillsAsync_NestedResourceFiles_DiscoveredAsync() [Fact] public async Task GetSkillsAsync_CustomResourceExtensions_UsedForDiscoveryAsync() { - // Arrange — use a source with custom extensions; files placed in spec folder + // Arrange — use a source with custom extensions; files placed in spec directory string skillDir = Path.Combine(this._testRoot, "custom-ext-skill"); string refsDir = Path.Combine(skillDir, "references"); Directory.CreateDirectory(refsDir); @@ -364,7 +364,7 @@ public void Constructor_MixOfValidAndInvalidExtensions_ThrowsArgumentException() [Fact] public async Task GetSkillsAsync_ResourceInSkillRoot_NotDiscoveredByDefaultAsync() { - // Arrange — resource files directly in the skill directory (not in a spec sub-folder) + // Arrange — resource files directly in the skill directory (not in a spec subdirectory) string skillDir = Path.Combine(this._testRoot, "root-resource-skill"); Directory.CreateDirectory(skillDir); File.WriteAllText(Path.Combine(skillDir, "guide.md"), "guide content"); @@ -377,15 +377,15 @@ public async Task GetSkillsAsync_ResourceInSkillRoot_NotDiscoveredByDefaultAsync // Act var skills = await source.GetSkillsAsync(); - // Assert — root-level files are NOT discovered unless "." is in ResourceFolders + // Assert — root-level files are NOT discovered unless "." is in ResourceDirectories Assert.Single(skills); Assert.Empty(skills[0].Resources!); } [Fact] - public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredWhenRootFolderConfiguredAsync() + public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredWhenRootDirectoryConfiguredAsync() { - // Arrange — "." in ResourceFolders opts into root-level resource discovery + // Arrange — "." in ResourceDirectories opts into root-level resource discovery string skillDir = Path.Combine(this._testRoot, "root-opt-in-skill"); Directory.CreateDirectory(skillDir); File.WriteAllText(Path.Combine(skillDir, "guide.md"), "guide content"); @@ -394,7 +394,7 @@ public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredWhenRootFolderCon Path.Combine(skillDir, "SKILL.md"), "---\nname: root-opt-in-skill\ndescription: Root opt-in\n---\nBody."); var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, - new AgentFileSkillsSourceOptions { ResourceFolders = ["references", "assets", "."] }); + new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "assets", "."] }); // Act var skills = await source.GetSkillsAsync(); @@ -408,31 +408,31 @@ public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredWhenRootFolderCon } [Fact] - public async Task GetSkillsAsync_ResourceInNonSpecFolder_NotDiscoveredByDefaultAsync() + public async Task GetSkillsAsync_ResourceInNonSpecDirectory_NotDiscoveredByDefaultAsync() { - // Arrange — resource in a non-spec folder (neither references/ nor assets/) + // Arrange — resource in a non-spec directory (neither references/ nor assets/) string skillDir = Path.Combine(this._testRoot, "non-spec-skill"); string customDir = Path.Combine(skillDir, "docs"); Directory.CreateDirectory(customDir); File.WriteAllText(Path.Combine(customDir, "readme.md"), "docs content"); File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), - "---\nname: non-spec-skill\ndescription: Non-spec folder\n---\nBody."); + "---\nname: non-spec-skill\ndescription: Non-spec directory\n---\nBody."); var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act var skills = await source.GetSkillsAsync(); - // Assert — non-spec folders are not scanned by default + // Assert — non-spec directories are not scanned by default Assert.Single(skills); Assert.Empty(skills[0].Resources!); } [Fact] - public async Task GetSkillsAsync_CustomResourceFolders_ReplacesDefaultsAsync() + public async Task GetSkillsAsync_CustomResourceDirectories_ReplacesDefaultsAsync() { - // Arrange — custom ResourceFolders replaces the spec defaults - string skillDir = Path.Combine(this._testRoot, "custom-folder-skill"); + // Arrange — custom ResourceDirectories replaces the spec defaults + string skillDir = Path.Combine(this._testRoot, "custom-directory-skill"); string customDir = Path.Combine(skillDir, "docs"); string refsDir = Path.Combine(skillDir, "references"); Directory.CreateDirectory(customDir); @@ -441,9 +441,9 @@ public async Task GetSkillsAsync_CustomResourceFolders_ReplacesDefaultsAsync() File.WriteAllText(Path.Combine(refsDir, "ref.md"), "ref content"); File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), - "---\nname: custom-folder-skill\ndescription: Custom folder\n---\nBody."); + "---\nname: custom-directory-skill\ndescription: Custom directory\n---\nBody."); var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, - new AgentFileSkillsSourceOptions { ResourceFolders = ["docs"] }); + new AgentFileSkillsSourceOptions { ResourceDirectories = ["docs"] }); // Act var skills = await source.GetSkillsAsync(); @@ -518,7 +518,7 @@ public async Task GetSkillsAsync_NestedSkillDirectory_DiscoveredWithinDepthLimit [Fact] public async Task ReadSkillResourceAsync_ValidResource_ReturnsContentAsync() { - // Arrange — create a skill with a resource file discovered from the references folder + // Arrange — create a skill with a resource file discovered from the references directory string skillDir = this.CreateSkillDirectory("read-skill", "A skill", "See docs for details."); string refsDir = Path.Combine(skillDir, "references"); Directory.CreateDirectory(refsDir); @@ -614,12 +614,12 @@ public async Task GetSkillsAsync_SymlinkInPath_SkipsSymlinkedResourcesAsync() } [Fact] - public async Task GetSkillsAsync_SymlinkedResourceFolder_SkipsWithoutEnumeratingAsync() + public async Task GetSkillsAsync_SymlinkedResourceDirectory_SkipsWithoutEnumeratingAsync() { // Arrange — references/ is a symlink pointing outside the skill directory. // The directory-level check should skip it entirely (no file enumeration), // so even files with valid extensions in the target are not discovered. - string skillDir = Path.Combine(this._testRoot, "symlink-folder-skip"); + string skillDir = Path.Combine(this._testRoot, "symlink-directory-skip"); string assetsDir = Path.Combine(skillDir, "assets"); Directory.CreateDirectory(assetsDir); File.WriteAllText(Path.Combine(assetsDir, "legit.md"), "legit content"); @@ -642,21 +642,21 @@ public async Task GetSkillsAsync_SymlinkedResourceFolder_SkipsWithoutEnumerating File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), - "---\nname: symlink-folder-skip\ndescription: Symlinked folder skip\n---\nBody."); + "---\nname: symlink-directory-skip\ndescription: Symlinked directory skip\n---\nBody."); var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act var skills = await source.GetSkillsAsync(); - // Assert — only assets/legit.md is found; the symlinked references/ folder is skipped entirely - var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-folder-skip"); + // Assert — only assets/legit.md is found; the symlinked references/ directory is skipped entirely + var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-directory-skip"); Assert.NotNull(skill); Assert.Single(skill.Resources!); Assert.Equal("assets/legit.md", skill.Resources![0].Name); } [Fact] - public async Task GetSkillsAsync_SymlinkedScriptFolder_SkipsWithoutEnumeratingAsync() + public async Task GetSkillsAsync_SymlinkedScriptDirectory_SkipsWithoutEnumeratingAsync() { // Arrange — scripts/ is a symlink pointing outside the skill directory. // The directory-level check should skip it entirely. @@ -679,22 +679,22 @@ public async Task GetSkillsAsync_SymlinkedScriptFolder_SkipsWithoutEnumeratingAs File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), - "---\nname: symlink-script-skip\ndescription: Symlinked script folder\n---\nBody."); + "---\nname: symlink-script-skip\ndescription: Symlinked script directory\n---\nBody."); var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act var skills = await source.GetSkillsAsync(); - // Assert — skill loads but scripts from the symlinked folder are not discovered + // Assert — skill loads but scripts from the symlinked directory are not discovered var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-script-skip"); Assert.NotNull(skill); Assert.Empty(skill.Scripts!); } [Fact] - public async Task GetSkillsAsync_SymlinkedIntermediateSegment_SkipsCustomFolderAsync() + public async Task GetSkillsAsync_SymlinkedIntermediateSegment_SkipsCustomDirectoryAsync() { - // Arrange — custom resource folder "sub/resources" where "sub" is a symlink. + // Arrange — custom resource directory "sub/resources" where "sub" is a symlink. // The directory-level HasSymlinkInPath check should detect the intermediate symlink. string skillDir = Path.Combine(this._testRoot, "symlink-intermediate"); Directory.CreateDirectory(skillDir); @@ -720,12 +720,12 @@ public async Task GetSkillsAsync_SymlinkedIntermediateSegment_SkipsCustomFolderA var source = new AgentFileSkillsSource( this._testRoot, s_noOpExecutor, - new AgentFileSkillsSourceOptions { ResourceFolders = ["sub/resources"] }); + new AgentFileSkillsSourceOptions { ResourceDirectories = ["sub/resources"] }); // Act var skills = await source.GetSkillsAsync(); - // Assert — the symlinked intermediate segment causes the folder to be skipped + // Assert — the symlinked intermediate segment causes the directory to be skipped var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-intermediate"); Assert.NotNull(skill); Assert.Empty(skill.Resources!); @@ -900,11 +900,11 @@ public async Task GetSkillsAsync_NoOptionalFields_DefaultsToNullAsync() [InlineData("sub/../escape")] [InlineData("/absolute")] [InlineData("\\absolute")] - public void Constructor_InvalidFolderName_SkipsInvalidFolders(string badFolder) + public void Constructor_InvalidDirectoryName_SkipsInvalidDirectories(string badDirectory) { - // Arrange & Act — invalid folders are skipped with a warning rather than throwing - var source1 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptFolders = [badFolder] }); - var source2 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceFolders = [badFolder] }); + // Arrange & Act — invalid directories are skipped with a warning rather than throwing + var source1 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptDirectories = [badDirectory] }); + var source2 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceDirectories = [badDirectory] }); // Assert Assert.NotNull(source1); @@ -915,54 +915,54 @@ public void Constructor_InvalidFolderName_SkipsInvalidFolders(string badFolder) [InlineData(null)] [InlineData("")] [InlineData(" ")] - public void Constructor_NullOrWhitespaceFolderName_ThrowsArgumentException(string? badFolder) + public void Constructor_NullOrWhitespaceDirectoryName_ThrowsArgumentException(string? badDirectory) { // Arrange & Act & Assert — null/whitespace is a contract violation, not a config error - Assert.Throws(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptFolders = [badFolder!] })); - Assert.Throws(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceFolders = [badFolder!] })); + Assert.Throws(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptDirectories = [badDirectory!] })); + Assert.Throws(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceDirectories = [badDirectory!] })); } [Theory] [InlineData("scripts")] [InlineData("my-scripts")] - [InlineData("sub/folder")] + [InlineData("sub/directory")] [InlineData(".")] [InlineData("./scripts")] [InlineData("./scripts/f1")] [InlineData("my..scripts")] - public void Constructor_ValidFolderName_DoesNotThrow(string validFolder) + public void Constructor_ValidDirectoryName_DoesNotThrow(string validDirectory) { // Arrange & Act & Assert - var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptFolders = [validFolder] }); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptDirectories = [validDirectory] }); Assert.NotNull(source); } [Fact] - public async Task GetSkillsAsync_DuplicateFoldersAfterNormalization_NoDuplicateResourcesAsync() + public async Task GetSkillsAsync_DuplicateDirectoriesAfterNormalization_NoDuplicateResourcesAsync() { // Arrange — "references" and "./references" refer to the same directory; // after normalization they should be deduplicated so resources appear only once. - string skillDir = Path.Combine(this._testRoot, "dedup-folder-skill"); + string skillDir = Path.Combine(this._testRoot, "dedup-directory-skill"); string refsDir = Path.Combine(skillDir, "references"); Directory.CreateDirectory(refsDir); File.WriteAllText(Path.Combine(refsDir, "FAQ.md"), "FAQ content"); File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), - "---\nname: dedup-folder-skill\ndescription: Dedup test\n---\nBody."); + "---\nname: dedup-directory-skill\ndescription: Dedup test\n---\nBody."); var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, - new AgentFileSkillsSourceOptions { ResourceFolders = ["references", "./references"] }); + new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "./references"] }); // Act var skills = await source.GetSkillsAsync(); - // Assert — only one copy of the resource despite two equivalent folder entries + // Assert — only one copy of the resource despite two equivalent directory entries Assert.Single(skills); Assert.Single(skills[0].Resources!); Assert.Equal("references/FAQ.md", skills[0].Resources![0].Name); } [Fact] - public async Task GetSkillsAsync_TrailingSlashFolderNormalized_NoDuplicateResourcesAsync() + public async Task GetSkillsAsync_TrailingSlashDirectoryNormalized_NoDuplicateResourcesAsync() { // Arrange — "references/" should be normalized to "references" string skillDir = Path.Combine(this._testRoot, "trailing-slash-skill"); @@ -973,7 +973,7 @@ public async Task GetSkillsAsync_TrailingSlashFolderNormalized_NoDuplicateResour Path.Combine(skillDir, "SKILL.md"), "---\nname: trailing-slash-skill\ndescription: Trailing slash test\n---\nBody."); var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, - new AgentFileSkillsSourceOptions { ResourceFolders = ["references", "references/"] }); + new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "references/"] }); // Act var skills = await source.GetSkillsAsync(); @@ -985,7 +985,7 @@ public async Task GetSkillsAsync_TrailingSlashFolderNormalized_NoDuplicateResour } [Fact] - public async Task GetSkillsAsync_BackslashFolderNormalized_NoDuplicateScriptsAsync() + public async Task GetSkillsAsync_BackslashDirectoryNormalized_NoDuplicateScriptsAsync() { // Arrange — ".\\scripts" should be normalized to "scripts" string skillDir = Path.Combine(this._testRoot, "backslash-skill"); @@ -996,7 +996,7 @@ public async Task GetSkillsAsync_BackslashFolderNormalized_NoDuplicateScriptsAsy Path.Combine(skillDir, "SKILL.md"), "---\nname: backslash-skill\ndescription: Backslash test\n---\nBody."); var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, - new AgentFileSkillsSourceOptions { ScriptFolders = ["scripts", ".\\scripts"] }); + new AgentFileSkillsSourceOptions { ScriptDirectories = ["scripts", ".\\scripts"] }); // Act var skills = await source.GetSkillsAsync(); @@ -1010,48 +1010,48 @@ public async Task GetSkillsAsync_BackslashFolderNormalized_NoDuplicateScriptsAsy [Theory] [InlineData("./references")] [InlineData("./assets/docs")] - public async Task GetSkillsAsync_ResourceFolderWithDotSlashPrefix_DiscoversResourcesAsync(string folder) + public async Task GetSkillsAsync_ResourceDirectoryWithDotSlashPrefix_DiscoversResourcesAsync(string directory) { // Arrange — "./references" and "./assets/docs" are equivalent to "references" and "assets/docs"; // the leading "./" is transparently normalized by Path.GetFullPath during file enumeration. - string folderWithoutDotSlash = folder.Substring(2); // strip "./" + string directoryWithoutDotSlash = directory.Substring(2); // strip "./" string skillDir = Path.Combine(this._testRoot, "dotslash-res-skill"); - string targetDir = Path.Combine(skillDir, folderWithoutDotSlash.Replace('/', Path.DirectorySeparatorChar)); + string targetDir = Path.Combine(skillDir, directoryWithoutDotSlash.Replace('/', Path.DirectorySeparatorChar)); Directory.CreateDirectory(targetDir); File.WriteAllText(Path.Combine(targetDir, "data.json"), "{}"); File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: dotslash-res-skill\ndescription: Dot-slash prefix\n---\nBody."); var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, - new AgentFileSkillsSourceOptions { ResourceFolders = [folder] }); + new AgentFileSkillsSourceOptions { ResourceDirectories = [directory] }); // Act var skills = await source.GetSkillsAsync(); - // Assert — the resource is discovered with a name identical to using the folder without "./" + // Assert — the resource is discovered with a name identical to using the directory without "./" Assert.Single(skills); Assert.Single(skills[0].Resources!); - Assert.Equal($"{folderWithoutDotSlash}/data.json", skills[0].Resources![0].Name); + Assert.Equal($"{directoryWithoutDotSlash}/data.json", skills[0].Resources![0].Name); } [Fact] - public async Task GetSkillsAsync_ResourceFoldersWithNestedPath_DiscoversResourcesAsync() + public async Task GetSkillsAsync_ResourceDirectoriesWithNestedPath_DiscoversResourcesAsync() { - // Arrange — ResourceFolders configured with a multi-segment relative path (f1/f2/f3) - string skillDir = Path.Combine(this._testRoot, "nested-folder-skill"); + // Arrange — ResourceDirectories configured with a multi-segment relative path (f1/f2/f3) + string skillDir = Path.Combine(this._testRoot, "nested-directory-skill"); string nestedDir = Path.Combine(skillDir, "f1", "f2", "f3"); Directory.CreateDirectory(nestedDir); File.WriteAllText(Path.Combine(nestedDir, "data.json"), "{}"); File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), - "---\nname: nested-folder-skill\ndescription: Nested folder\n---\nBody."); + "---\nname: nested-directory-skill\ndescription: Nested directory\n---\nBody."); var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, - new AgentFileSkillsSourceOptions { ResourceFolders = ["f1/f2/f3"] }); + new AgentFileSkillsSourceOptions { ResourceDirectories = ["f1/f2/f3"] }); // Act var skills = await source.GetSkillsAsync(); - // Assert — resource file inside the deeply nested folder is discovered + // Assert — resource file inside the deeply nested directory is discovered Assert.Single(skills); var skill = skills[0]; Assert.Single(skill.Resources!); @@ -1107,9 +1107,9 @@ public async Task GetSkillsAsync_SkillBeyondMaxDepth_NotDiscoveredAsync() } [Fact] - public async Task GetSkillsAsync_ScriptInSkillRoot_DiscoveredWhenRootFolderConfiguredAsync() + public async Task GetSkillsAsync_ScriptInSkillRoot_DiscoveredWhenRootDirectoryConfiguredAsync() { - // Arrange — script file directly in the skill directory with ScriptFolders = ["."] + // Arrange — script file directly in the skill directory with ScriptDirectories = ["."] string skillDir = Path.Combine(this._testRoot, "root-script-skill"); Directory.CreateDirectory(skillDir); File.WriteAllText(Path.Combine(skillDir, "run.py"), "print('hello')"); @@ -1117,7 +1117,7 @@ public async Task GetSkillsAsync_ScriptInSkillRoot_DiscoveredWhenRootFolderConfi Path.Combine(skillDir, "SKILL.md"), "---\nname: root-script-skill\ndescription: Root script\n---\nBody."); var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, - new AgentFileSkillsSourceOptions { ScriptFolders = ["."] }); + new AgentFileSkillsSourceOptions { ScriptDirectories = ["."] }); // Act var skills = await source.GetSkillsAsync(); @@ -1131,7 +1131,7 @@ public async Task GetSkillsAsync_ScriptInSkillRoot_DiscoveredWhenRootFolderConfi #if NET [Fact] - public async Task GetSkillsAsync_SymlinkedFileInRealFolder_SkipsSymlinkedFileAsync() + public async Task GetSkillsAsync_SymlinkedFileInRealDirectory_SkipsSymlinkedFileAsync() { // Arrange — references/ is a real directory, but one file inside it is a symlink // pointing outside the skill directory. The per-file symlink check should skip it.