Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
20 changes: 18 additions & 2 deletions eng/skill-validator/src/Commands/CheckCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,11 +111,27 @@ private static async Task<int> RunPluginCheck(CheckConfig config)
allPlugins.Add(plugin);

// Resolve skills path from plugin.json and discover skills per plugin
if (!string.IsNullOrWhiteSpace(plugin?.SkillsPath)
// Prefer the array form, fall back to string.
var skillsDirs = new List<string>();
if (plugin?.SkillPaths is { Count: > 0 })
{
foreach (var sp in plugin.SkillPaths)
{
if (PluginValidator.TryGetSafeSubdirectory(fullPath, sp, out var dir, out _)
&& Directory.Exists(dir))
skillsDirs.Add(dir!);
}
}
else if (!string.IsNullOrWhiteSpace(plugin?.SkillsPath)
&& PluginValidator.TryGetSafeSubdirectory(fullPath, plugin.SkillsPath, out var skillsDir, out _)
&& Directory.Exists(skillsDir))
{
var skills = await SkillDiscovery.DiscoverSkills(skillsDir!);
skillsDirs.Add(skillsDir!);
}

foreach (var dir in skillsDirs)
{
var skills = await SkillDiscovery.DiscoverSkills(dir);
pluginSkills[pluginName] = new List<SkillInfo>(skills);
allSkillsList.AddRange(skills);
}
Expand Down
4 changes: 3 additions & 1 deletion eng/skill-validator/src/Models/Models.cs
Comment thread
ViktorHofer marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,9 @@ public sealed record PluginInfo(
string? SkillsPath,
string? AgentsPath,
string DirectoryPath,
string DirectoryName);
string DirectoryName,
IReadOnlyList<string>? AgentPaths = null,
IReadOnlyList<string>? SkillPaths = null);

public sealed record PluginValidationResult(
string Name,
Expand Down
19 changes: 18 additions & 1 deletion eng/skill-validator/src/Services/AgentRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,24 @@ internal static string[] ResolvePluginSkillDirectories(string pluginRoot)
// without extra skill directories; validation surfaces the real error.
return [];
}
if (pluginInfo?.SkillsPath is null) return [];
if (pluginInfo is null) return [];

// Prefer the array form (Claude Code schema).
if (pluginInfo.SkillPaths is { Count: > 0 })
{
var dirs = new List<string>();
foreach (var relativePath in pluginInfo.SkillPaths)
{
if (!PluginValidator.TryGetSafeSubdirectory(pluginRoot, relativePath, out var fullPath, out _))
continue;
if (Directory.Exists(fullPath!))
dirs.Add(fullPath!);
}
return dirs.ToArray();
}

// Fall back to string path.
if (pluginInfo.SkillsPath is null) return [];

if (!PluginValidator.TryGetSafeSubdirectory(
pluginRoot, pluginInfo.SkillsPath, out var skillsDir, out _))
Expand Down
79 changes: 74 additions & 5 deletions eng/skill-validator/src/Services/PluginValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,22 @@ public static PluginValidationResult ValidatePlugin(PluginInfo plugin)
}

// --- Skills path validation ---
if (string.IsNullOrWhiteSpace(plugin.SkillsPath))
// skills can be an array of directory paths or a single string directory.
if (plugin.SkillPaths is { Count: > 0 })
{
foreach (var skillPath in plugin.SkillPaths)
{
if (!TryGetSafeSubdirectory(plugin.DirectoryPath, skillPath, out var resolved, out var skillPathError))
{
errors.Add($"Plugin skills path is invalid: {skillPathError}");
}
else if (!Directory.Exists(resolved!) && !File.Exists(resolved!))
{
errors.Add($"Plugin skills path '{skillPath}' does not exist at '{resolved}'.");
}
}
}
else if (string.IsNullOrWhiteSpace(plugin.SkillsPath))
{
errors.Add("plugin.json has no 'skills' field — required.");
}
Expand All @@ -59,7 +74,28 @@ public static PluginValidationResult ValidatePlugin(PluginInfo plugin)
}

// --- Agents path validation (optional, but warn if specified and missing) ---
if (!string.IsNullOrWhiteSpace(plugin.AgentsPath))
// agents can be an array of directory/file paths or a single string directory.
if (plugin.AgentPaths is { Count: > 0 })
{
foreach (var agentPath in plugin.AgentPaths)
{
Comment thread
ViktorHofer marked this conversation as resolved.
Outdated
if (string.IsNullOrWhiteSpace(agentPath))
{
warnings.Add("Plugin agents entry is empty or whitespace and will be ignored.");
continue;
}

if (!TryGetSafeSubdirectory(plugin.DirectoryPath, agentPath, out var resolved, out var agentPathError))
{
warnings.Add($"Plugin agent path is invalid: {agentPathError}");
}
else if (!Directory.Exists(resolved!) && !File.Exists(resolved!))
{
warnings.Add($"Plugin agent path '{agentPath}' does not exist at '{resolved}'.");
}
}
}
else if (!string.IsNullOrWhiteSpace(plugin.AgentsPath))
{
if (!TryGetSafeSubdirectory(plugin.DirectoryPath, plugin.AgentsPath, out var agentsDir, out var agentsPathError))
{
Expand Down Expand Up @@ -129,12 +165,45 @@ internal static bool TryGetSafeSubdirectory(string rootDirectory, string relativ
var name = doc.TryGetProperty("name", out var n) ? n.GetString() : null;
var version = doc.TryGetProperty("version", out var v) ? v.GetString() : null;
var description = doc.TryGetProperty("description", out var d) ? d.GetString() : null;
var skills = doc.TryGetProperty("skills", out var s) ? s.GetString() : null;
var agents = doc.TryGetProperty("agents", out var a) ? a.GetString() : null;
// skills and agents can be an array of strings (Claude Code schema, preferred)
// or a string path (legacy). Read the array first, fall back to string.
string? skillsPath = null;
IReadOnlyList<string>? skillPaths = null;
if (doc.TryGetProperty("skills", out var s))
{
if (s.ValueKind == JsonValueKind.Array)
{
skillPaths = s.EnumerateArray()
.Where(e => e.ValueKind == JsonValueKind.String)
.Select(e => e.GetString()!)
.ToList();
}
else if (s.ValueKind == JsonValueKind.String)
{
skillsPath = s.GetString();
}
}

string? agentsPath = null;
IReadOnlyList<string>? agentPaths = null;
if (doc.TryGetProperty("agents", out var a))
{
if (a.ValueKind == JsonValueKind.Array)
{
agentPaths = a.EnumerateArray()
.Where(e => e.ValueKind == JsonValueKind.String)
.Select(e => e.GetString()!)
.ToList();
}
else if (a.ValueKind == JsonValueKind.String)
{
agentsPath = a.GetString();
}
}

var dirPath = Path.GetDirectoryName(Path.GetFullPath(pluginJsonPath))!;
var dirName = Path.GetFileName(dirPath);

return new PluginInfo(name ?? "", version, description, skills, agents, dirPath, dirName);
return new PluginInfo(name ?? "", version, description, skillsPath, agentsPath, dirPath, dirName, agentPaths, skillPaths);
}
}
53 changes: 50 additions & 3 deletions eng/skill-validator/src/Services/SkillDiscovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ public static async Task<IReadOnlyList<SkillInfo>> DiscoverSkillsRecursive(strin

/// <summary>
/// Discover skills within a plugin root directory.
/// Uses plugin.json to determine the skills path.
/// Uses plugin.json to determine the skills path: prefers the array form,
/// falls back to the string path.
/// </summary>
public static async Task<IReadOnlyList<SkillInfo>> DiscoverSkillsInPlugin(string pluginRoot)
{
Expand All @@ -67,7 +68,25 @@ public static async Task<IReadOnlyList<SkillInfo>> DiscoverSkillsInPlugin(string
return [];

var plugin = PluginValidator.ParsePluginJson(pluginJsonPath);
if (plugin is null || string.IsNullOrWhiteSpace(plugin.SkillsPath))
if (plugin is null)
return [];

// Prefer the array form (Claude Code schema).
if (plugin.SkillPaths is { Count: > 0 })
{
var skills = new List<SkillInfo>();
foreach (var relativePath in plugin.SkillPaths)
{
if (!PluginValidator.TryGetSafeSubdirectory(pluginRoot, relativePath, out var fullPath, out _))
continue;
if (Directory.Exists(fullPath!))
skills.AddRange(await DiscoverSkills(fullPath!));
}
return skills;
}

// Fall back to string path.
if (string.IsNullOrWhiteSpace(plugin.SkillsPath))
return [];

if (!PluginValidator.TryGetSafeSubdirectory(pluginRoot, plugin.SkillsPath, out var skillsDir, out _))
Expand Down Expand Up @@ -253,7 +272,8 @@ public static async Task<IReadOnlyList<AgentInfo>> DiscoverAgents(IReadOnlyList<

/// <summary>
/// Discover agent files (.agent.md) within a plugin root directory.
/// Uses plugin.json to determine the agents path.
/// Uses plugin.json to determine the agents: prefers the array form,
/// falls back to the string path, then to the "agents" directory by convention.
/// </summary>
public static async Task<IReadOnlyList<AgentInfo>> DiscoverAgentsInPlugin(string pluginRoot)
{
Expand All @@ -265,6 +285,33 @@ public static async Task<IReadOnlyList<AgentInfo>> DiscoverAgentsInPlugin(string
if (plugin is null)
return [];

// Prefer the array form (Claude Code schema).
// Each entry may be a directory (discover all .agent.md in it) or a file.
if (plugin.AgentPaths is { Count: > 0 })
{
var agents = new List<AgentInfo>();
foreach (var relativePath in plugin.AgentPaths)
{
Comment thread
ViktorHofer marked this conversation as resolved.
Outdated
if (string.IsNullOrWhiteSpace(relativePath))
continue;

if (!PluginValidator.TryGetSafeSubdirectory(pluginRoot, relativePath, out var fullPath, out _))
continue;
if (Directory.Exists(fullPath!))
{
agents.AddRange(await DiscoverAgentsInDirectory(fullPath!));
}
else
{
var agent = await DiscoverAgentAt(fullPath!);
if (agent is not null)
agents.Add(agent);
}
}
return agents;
}

// Fall back to string path or "agents" directory convention.
var agentsPath = !string.IsNullOrWhiteSpace(plugin.AgentsPath)
? plugin.AgentsPath
: "agents";
Expand Down
Loading