Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
3 changes: 2 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,8 @@ public sealed record PluginInfo(
string? SkillsPath,
string? AgentsPath,
string DirectoryPath,
string DirectoryName);
string DirectoryName,
IReadOnlyList<string>? AgentPaths = null);

public sealed record PluginValidationResult(
string Name,
Expand Down
38 changes: 35 additions & 3 deletions eng/skill-validator/src/Services/PluginValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,22 @@ 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 (!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 @@ -130,11 +145,28 @@ internal static bool TryGetSafeSubdirectory(string rootDirectory, string relativ
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;
// 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? 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, skills, agentsPath, dirPath, dirName, agentPaths);
}
}
27 changes: 26 additions & 1 deletion eng/skill-validator/src/Services/SkillDiscovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,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 +266,30 @@ 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 (!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
93 changes: 92 additions & 1 deletion eng/skill-validator/tests/AgentPluginTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,52 @@ public void NonexistentAgentsPathWarns()
}
}

[Fact]
public void NonexistentAgentPathInArrayWarns()
{
var pluginDir = Path.Combine(Path.GetTempPath(), "plugin-test-" + Guid.NewGuid().ToString("N"));
try
{
Directory.CreateDirectory(pluginDir);
Directory.CreateDirectory(Path.Combine(pluginDir, "skills"));
var dirName = Path.GetFileName(pluginDir);

var plugin = new PluginInfo(dirName, "1.0.0", "desc", "./skills/", null, pluginDir, dirName,
AgentPaths: ["./agents/"]);
var result = PluginValidator.ValidatePlugin(plugin);
Assert.Empty(result.Errors);
Assert.Contains(result.Warnings, w => w.Contains("does not exist"));
}
finally
{
Directory.Delete(pluginDir, true);
}
}

[Fact]
public void ValidAgentPathsArrayProducesNoWarnings()
{
var pluginDir = Path.Combine(Path.GetTempPath(), "plugin-test-" + Guid.NewGuid().ToString("N"));
try
{
Directory.CreateDirectory(pluginDir);
Directory.CreateDirectory(Path.Combine(pluginDir, "skills"));
Directory.CreateDirectory(Path.Combine(pluginDir, "agents"));
File.WriteAllText(Path.Combine(pluginDir, "agents", "test.agent.md"), "---\nname: test\ndescription: test\n---\n# Test\n");
var dirName = Path.GetFileName(pluginDir);

var plugin = new PluginInfo(dirName, "1.0.0", "desc", "./skills/", null, pluginDir, dirName,
AgentPaths: ["./agents/"]);
var result = PluginValidator.ValidatePlugin(plugin);
Assert.Empty(result.Errors);
Assert.Empty(result.Warnings);
}
finally
{
Directory.Delete(pluginDir, true);
}
}

[Fact]
public void NameFormatErrors()
{
Expand Down Expand Up @@ -301,15 +347,60 @@ public void ParsePluginJsonParsesValidFile()
{
Directory.CreateDirectory(dir);
var jsonPath = Path.Combine(dir, "plugin.json");
File.WriteAllText(jsonPath, """{"name":"my-plugin","version":"0.1.0","description":"A plugin.","skills":"./skills/","agents":"./agents/"}""");
File.WriteAllText(jsonPath, """{"name":"my-plugin","version":"0.1.0","description":"A plugin.","skills":"./skills/","agents":["./agents/"]}""");

var plugin = PluginValidator.ParsePluginJson(jsonPath);
Assert.NotNull(plugin);
Assert.Equal("my-plugin", plugin.Name);
Assert.Equal("0.1.0", plugin.Version);
Assert.Equal("A plugin.", plugin.Description);
Assert.Equal("./skills/", plugin.SkillsPath);
Assert.Null(plugin.AgentsPath);
Assert.NotNull(plugin.AgentPaths);
Assert.Single(plugin.AgentPaths);
Assert.Equal("./agents/", plugin.AgentPaths[0]);
}
finally
{
Directory.Delete(dir, true);
}
}

[Fact]
public void ParsePluginJsonParsesAgentsString()
{
var dir = Path.Combine(Path.GetTempPath(), "parse-test-" + Guid.NewGuid().ToString("N"));
try
{
Directory.CreateDirectory(dir);
var jsonPath = Path.Combine(dir, "plugin.json");
File.WriteAllText(jsonPath, """{"name":"my-plugin","version":"0.1.0","description":"A plugin.","skills":"./skills/","agents":"./agents/"}""");

var plugin = PluginValidator.ParsePluginJson(jsonPath);
Assert.NotNull(plugin);
Assert.Equal("./agents/", plugin.AgentsPath);
Assert.Null(plugin.AgentPaths);
}
finally
{
Directory.Delete(dir, true);
}
}

[Fact]
public void ParsePluginJsonNoAgentsField()
{
var dir = Path.Combine(Path.GetTempPath(), "parse-test-" + Guid.NewGuid().ToString("N"));
try
{
Directory.CreateDirectory(dir);
var jsonPath = Path.Combine(dir, "plugin.json");
File.WriteAllText(jsonPath, """{"name":"my-plugin","version":"0.1.0","description":"A plugin.","skills":"./skills/"}""");

var plugin = PluginValidator.ParsePluginJson(jsonPath);
Assert.NotNull(plugin);
Assert.Null(plugin.AgentsPath);
Assert.Null(plugin.AgentPaths);
}
finally
{
Expand Down
2 changes: 1 addition & 1 deletion plugins/dotnet-diag/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
"version": "0.1.0",
"description": "Skills for .NET performance investigations, debugging, and incident analysis.",
"skills": "./skills/",
"agents": "./agents/"
"agents": ["./agents/"]
}
2 changes: 1 addition & 1 deletion plugins/dotnet-msbuild/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
"version": "0.1.0",
"description": "Comprehensive MSBuild and .NET build skills: failure diagnosis, performance optimization, code quality, and modernization.",
"skills": "./skills/",
"agents": "./agents/"
"agents": ["./agents/"]
}
2 changes: 1 addition & 1 deletion plugins/dotnet-template-engine/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
"version": "0.1.0",
"description": ".NET Template Engine skills: template discovery, project scaffolding, and template authoring.",
"skills": "./skills/",
"agents": "./agents/"
"agents": ["./agents/"]
}
Loading