From b9c67bef567a7e91858dd47caa02c97f89fb06bf Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:04:30 +0000 Subject: [PATCH] Standardize file skills terminology on 'directory' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename authored identifiers, XML docs, log messages, and comments from 'folder' to 'directory' across the file skills codebase for consistency with the agentskills.io specification and .NET conventions. Public API changes (experimental): - ScriptFolders → ScriptDirectories - ResourceFolders → ResourceDirectories .NET BCL API calls (Directory.Exists, Path.GetDirectoryName, etc.) were already using 'directory' and are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Skills/File/AgentFileSkillsSource.cs | 108 +++++++-------- .../File/AgentFileSkillsSourceOptions.cs | 8 +- .../AgentFileSkillsSourceScriptTests.cs | 36 ++--- .../AgentSkills/FileAgentSkillLoaderTests.cs | 130 +++++++++--------- 4 files changed, 141 insertions(+), 141 deletions(-) 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.