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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 210 additions & 0 deletions src/Netclaw.SkillServer.Cli/Commands/LintCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
// -----------------------------------------------------------------------
// <copyright file="LintCommand.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------

using Netclaw.SkillServer.Cli.Output;
using Netclaw.SkillServer.Cli.Publishing;

namespace Netclaw.SkillServer.Cli.Commands;

/// <summary>
/// Result of validating a single skill directory.
/// </summary>
public sealed record SkillLintResult(
IReadOnlyList<string> Issues,
IReadOnlyList<string> Warnings);

/// <summary>
/// Lint command: validates skill directories against the AgentSkills.io spec
/// and reports issues. Used in CI to catch problems before publishing.
/// </summary>
internal static class LintCommand
{
public static async Task<int> ExecuteAsync(ParsedArgs args)
{
if (args.Help || args.Positional.Count == 0)
{
PrintHelp();
return args.Help ? 0 : 1;
}

var path = args.Positional[0];
if (!Directory.Exists(path))
{
ConsoleOutput.WriteError($"Error: Directory '{path}' does not exist.");
return 1;
}

var dir = Path.GetFullPath(path);
var issues = new List<string>();
var warnings = new List<string>();
var validated = 0;

// Scan each subdirectory for a skill
var subDirs = Directory.EnumerateDirectories(dir)
.OrderBy(d => d, StringComparer.OrdinalIgnoreCase)
.ToList();

foreach (var subDir in subDirs)
{
var skillName = Path.GetFileName(subDir);
var skillMdPath = Path.Combine(subDir, "SKILL.md");

if (!File.Exists(skillMdPath))
{
// Check if this looks like it should be a skill (has files but no SKILL.md)
var hasMd = Directory.GetFiles(subDir, "*.md", SearchOption.TopDirectoryOnly)
.Any(f => Path.GetFileName(f).ToLowerInvariant() != "readme.md");
var hasSubdirs = Directory.GetDirectories(subDir).Any();

if (hasMd || hasSubdirs)
{
warnings.Add($"{skillName}: No SKILL.md found — directory may be incomplete");
}
continue;
}

var content = File.ReadAllText(skillMdPath);
var skillIssues = ValidateSkill(skillName, skillMdPath, content);
issues.AddRange(skillIssues.Issues);
warnings.AddRange(skillIssues.Warnings);

if (skillIssues.Issues.Count == 0)
validated++;
}

// Print results
ConsoleOutput.WriteInfo($"Linting '{path}'...");
ConsoleOutput.WriteInfo($"Found {subDirs.Count} potential skill directory(ies).");
Console.WriteLine();

bool hasErrors = false;

foreach (var issue in issues)
{
ConsoleOutput.WriteError($"✗ {issue}");
hasErrors = true;
}

foreach (var warning in warnings)
{
ConsoleOutput.WriteWarning($"⚠ {warning}");
}

Console.WriteLine();

if (!hasErrors)
{
ConsoleOutput.WriteSuccess($"All skills valid ({validated} passed, {warnings.Count} warning(s))");
}
else
{
ConsoleOutput.WriteError($"{issues.Count} error(s), {warnings.Count} warning(s) — lint failed");
}

return hasErrors ? 1 : 0;
}

/// <summary>
/// Validate a single skill's SKILL.md content against the AgentSkills.io spec.
/// </summary>
internal static SkillLintResult ValidateSkill(
string skillName, string skillMdPath, string content)
{
var issues = new List<string>();
var warnings = new List<string>();

// 1. Check frontmatter exists
var frontmatter = SkillDirectoryScanner.ParseFrontmatter(content);
if (frontmatter is null)
{
issues.Add($"{skillName}: No YAML frontmatter found in SKILL.md");
return new SkillLintResult(issues, warnings);
}

// 2. Required: name
var name = frontmatter.GetValueOrDefault("name");
if (string.IsNullOrWhiteSpace(name))
{
issues.Add($"{skillName}: Missing required 'name' field in frontmatter");
}
else
{
// Check name matches directory name
var dirName = Path.GetFileName(Path.GetDirectoryName(skillMdPath)!);
if (!string.Equals(name, dirName, StringComparison.OrdinalIgnoreCase))
{
warnings.Add($"{skillName}: Frontmatter 'name' ({name}) does not match directory name ({dirName})");
}

// Validate name format (lowercase alphanumeric and hyphens)
if (!System.Text.RegularExpressions.Regex.IsMatch(name, @"^[a-z0-9][a-z0-9\-]*$"))
{
issues.Add($"{skillName}: Invalid 'name' format '{name}' — must be lowercase alphanumeric with hyphens");
}
}

// 3. Required: version
var version = frontmatter.GetValueOrDefault("version");
if (string.IsNullOrWhiteSpace(version))
{
issues.Add($"{skillName}: Missing required 'version' field in frontmatter");
}
else
{
// Validate semantic version format (basic check)
if (!System.Text.RegularExpressions.Regex.IsMatch(version, @"^\d+\.\d+\.\d+"))
{
warnings.Add($"{skillName}: 'version' '{version}' does not follow semantic versioning (x.y.z)");
}
}

// 4. Recommended: description
var description = frontmatter.GetValueOrDefault("description");
if (string.IsNullOrWhiteSpace(description))
{
warnings.Add($"{skillName}: Missing recommended 'description' field in frontmatter");
}

// 5. Check body has content after frontmatter (find the CLOSING ---, not the opening)
var firstDash = content.IndexOf("---\n", StringComparison.Ordinal);
if (firstDash >= 0)
{
var secondDash = content.IndexOf("---\n", firstDash + 4, StringComparison.Ordinal);
if (secondDash >= 0)
{
var body = content[(secondDash + 4)..].Trim();
if (string.IsNullOrEmpty(body))
{
warnings.Add($"{skillName}: SKILL.md has no body content after frontmatter");
}
}
}

return new SkillLintResult(issues, warnings);
}

private static void PrintHelp()
{
Console.WriteLine("Usage: skillserver lint <path>");
Console.WriteLine();
Console.WriteLine("Validate all skills in a directory against the AgentSkills.io specification.");
Console.WriteLine("Checks for required fields, format issues, and common problems.");
Console.WriteLine();
Console.WriteLine("Returns exit code 1 if any errors are found (suitable for CI).");
Console.WriteLine();
Console.WriteLine("Arguments:");
Console.WriteLine(" <path> Parent directory containing skill subdirectories");
Console.WriteLine();
Console.WriteLine("Validates:");
Console.WriteLine(" - YAML frontmatter exists");
Console.WriteLine(" - Required 'name' field present");
Console.WriteLine(" - Required 'version' field present");
Console.WriteLine(" - Name matches directory name");
Console.WriteLine(" - Name format (lowercase alphanumeric with hyphens)");
Console.WriteLine(" - Semantic version format");
Console.WriteLine(" - Description present (warning if missing)");
}
}
5 changes: 5 additions & 0 deletions src/Netclaw.SkillServer.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@
if (parsedArgs.Command == "config")
return await ConfigCommand.ExecuteAsync(parsedArgs);

// lint operates entirely on local files — no server URL or auth needed
if (parsedArgs.Command == "lint")
return await LintCommand.ExecuteAsync(parsedArgs);

// --help on subcommands works without auth
if (parsedArgs.Help)
{
Expand Down Expand Up @@ -115,6 +119,7 @@ static void PrintHelp()
Console.WriteLine("Commands:");
Console.WriteLine(" publish <path> Publish a skill directory to the server");
Console.WriteLine(" publish-all <path> Batch-publish all skills in a directory");
Console.WriteLine(" lint <path> Validate skills against spec (no auth required)");
Console.WriteLine(" delete <name> <version> Delete a published skill version");
Console.WriteLine(" list List skills on the server");
Console.WriteLine(" versions <name> List all versions of a skill");
Expand Down
Loading
Loading