diff --git a/src/Netclaw.SkillServer.Cli/Commands/LintCommand.cs b/src/Netclaw.SkillServer.Cli/Commands/LintCommand.cs
new file mode 100644
index 0000000..68c056a
--- /dev/null
+++ b/src/Netclaw.SkillServer.Cli/Commands/LintCommand.cs
@@ -0,0 +1,210 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Petabridge, LLC
+//
+// -----------------------------------------------------------------------
+
+using Netclaw.SkillServer.Cli.Output;
+using Netclaw.SkillServer.Cli.Publishing;
+
+namespace Netclaw.SkillServer.Cli.Commands;
+
+///
+/// Result of validating a single skill directory.
+///
+public sealed record SkillLintResult(
+ IReadOnlyList Issues,
+ IReadOnlyList Warnings);
+
+///
+/// Lint command: validates skill directories against the AgentSkills.io spec
+/// and reports issues. Used in CI to catch problems before publishing.
+///
+internal static class LintCommand
+{
+ public static async Task 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();
+ var warnings = new List();
+ 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;
+ }
+
+ ///
+ /// Validate a single skill's SKILL.md content against the AgentSkills.io spec.
+ ///
+ internal static SkillLintResult ValidateSkill(
+ string skillName, string skillMdPath, string content)
+ {
+ var issues = new List();
+ var warnings = new List();
+
+ // 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 ");
+ 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(" 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)");
+ }
+}
diff --git a/src/Netclaw.SkillServer.Cli/Program.cs b/src/Netclaw.SkillServer.Cli/Program.cs
index bf232d6..b320304 100644
--- a/src/Netclaw.SkillServer.Cli/Program.cs
+++ b/src/Netclaw.SkillServer.Cli/Program.cs
@@ -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)
{
@@ -115,6 +119,7 @@ static void PrintHelp()
Console.WriteLine("Commands:");
Console.WriteLine(" publish Publish a skill directory to the server");
Console.WriteLine(" publish-all Batch-publish all skills in a directory");
+ Console.WriteLine(" lint Validate skills against spec (no auth required)");
Console.WriteLine(" delete Delete a published skill version");
Console.WriteLine(" list List skills on the server");
Console.WriteLine(" versions List all versions of a skill");
diff --git a/tests/Netclaw.SkillServer.Cli.Tests/LintCommandTests.cs b/tests/Netclaw.SkillServer.Cli.Tests/LintCommandTests.cs
new file mode 100644
index 0000000..59d5575
--- /dev/null
+++ b/tests/Netclaw.SkillServer.Cli.Tests/LintCommandTests.cs
@@ -0,0 +1,396 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Petabridge, LLC
+//
+// -----------------------------------------------------------------------
+
+using Netclaw.SkillServer.Cli.Commands;
+using Xunit;
+
+namespace Netclaw.SkillServer.Cli.Tests;
+
+public sealed class LintCommandTests : IDisposable
+{
+ private readonly string _tempDir;
+
+ public LintCommandTests()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), "skillserver-lint-test-" + Guid.NewGuid().ToString("N")[..8]);
+ Directory.CreateDirectory(_tempDir);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_tempDir))
+ Directory.Delete(_tempDir, true);
+ }
+
+ private string CreateSkillDirectory(string name, string skillMdContent)
+ {
+ var dir = Path.Combine(_tempDir, name);
+ Directory.CreateDirectory(dir);
+ File.WriteAllText(Path.Combine(dir, "SKILL.md"), skillMdContent);
+ return dir;
+ }
+
+ #region Valid skills
+
+ [Fact]
+ public void ValidateSkill_ValidSkill_NoIssuesOrWarnings()
+ {
+ var content = """
+ ---
+ name: my-skill
+ version: 1.0.0
+ description: A test skill
+ ---
+ # My Skill
+
+ Content here.
+ """;
+
+ var result = LintCommand.ValidateSkill("my-skill", "skills/my-skill/SKILL.md", content);
+
+ Assert.Empty(result.Issues);
+ Assert.Empty(result.Warnings);
+ }
+
+ [Theory]
+ [InlineData("1.0.0")]
+ [InlineData("0.1.0")]
+ [InlineData("2.13.7")]
+ [InlineData("10.0.0-alpha")]
+ public void ValidateSkill_ValidSemVer_NoWarning(string version)
+ {
+ var content = $"""
+ ---
+ name: my-skill
+ version: {version}
+ description: A test skill
+ ---
+ # My Skill
+ """;
+
+ var result = LintCommand.ValidateSkill("my-skill", "skills/my-skill/SKILL.md", content);
+
+ Assert.DoesNotContain(result.Warnings, w => w.Contains("semantic versioning"));
+ }
+
+ [Theory]
+ [InlineData("my-skill")]
+ [InlineData("a")]
+ [InlineData("my-skill-123")]
+ [InlineData("123skill")]
+ public void ValidateSkill_ValidNameFormat_NoIssue(string name)
+ {
+ var content = $"""
+ ---
+ name: {name}
+ version: 1.0.0
+ description: A test skill
+ ---
+ # My Skill
+ """;
+
+ var result = LintCommand.ValidateSkill(name, $"skills/{name}/SKILL.md", content);
+
+ Assert.DoesNotContain(result.Issues, i => i.Contains("Invalid 'name' format"));
+ }
+
+ #endregion
+
+ #region Missing required fields
+
+ [Theory]
+ [InlineData("""
+ ---
+ version: 1.0.0
+ description: Missing name
+ ---
+ # Content
+ """, "Missing required 'name'")]
+ [InlineData("""
+ ---
+ name: no-version
+ description: Missing version
+ ---
+ # Content
+ """, "Missing required 'version'")]
+ [InlineData("""
+ ---
+ description: Missing both
+ ---
+ # Content
+ """, "Missing required 'name'")]
+ public void ValidateSkill_MissingRequiredFields_ReportsIssues(string content, string expectedError)
+ {
+ var result = LintCommand.ValidateSkill("test-skill", "skills/test-skill/SKILL.md", content);
+
+ Assert.NotEmpty(result.Issues);
+ Assert.Contains(result.Issues, i => i.Contains(expectedError));
+ }
+
+ [Fact]
+ public void ValidateSkill_MissingBothNameAndVersion_TwoIssues()
+ {
+ var content = """
+ ---
+ description: Missing both
+ ---
+ # Content
+ """;
+
+ var result = LintCommand.ValidateSkill("test-skill", "skills/test-skill/SKILL.md", content);
+
+ Assert.Equal(2, result.Issues.Count);
+ Assert.Contains(result.Issues, i => i.Contains("Missing required 'name'"));
+ Assert.Contains(result.Issues, i => i.Contains("Missing required 'version'"));
+ }
+
+ #endregion
+
+ #region Frontmatter
+
+ [Fact]
+ public void ValidateSkill_NoFrontmatter_ReportIssue()
+ {
+ var content = "# Just content, no frontmatter";
+
+ var result = LintCommand.ValidateSkill("test-skill", "skills/test-skill/SKILL.md", content);
+
+ Assert.Single(result.Issues);
+ Assert.Contains("No YAML frontmatter", result.Issues[0]);
+ }
+
+ [Fact]
+ public void ValidateSkill_EmptyBody_HasWarning()
+ {
+ // Must start with --- at position 0 for the frontmatter regex to match
+ var content = "---\nname: test-skill\nversion: 1.0.0\ndescription: A test skill\n---\n";
+
+ var result = LintCommand.ValidateSkill("test-skill", "skills/test-skill/SKILL.md", content);
+
+ Assert.Empty(result.Issues);
+ Assert.Contains(result.Warnings, w => w.Contains("no body content"));
+ }
+
+ #endregion
+
+ #region Warnings
+
+ [Theory]
+ [InlineData("MySkill", "uppercase")]
+ [InlineData("test-skill", "camelCase")]
+ public void ValidateSkill_NameMismatch_Warning(string dirName, string frontmatterName)
+ {
+ var content = $"""
+ ---
+ name: {frontmatterName}
+ version: 1.0.0
+ description: A test skill
+ ---
+ # My Skill
+ """;
+
+ var result = LintCommand.ValidateSkill(dirName, $"skills/{dirName}/SKILL.md", content);
+
+ Assert.Contains(result.Warnings, w => w.Contains("does not match directory name"));
+ }
+
+ [Fact]
+ public void ValidateSkill_MissingDescription_Warning()
+ {
+ var content = """
+ ---
+ name: test-skill
+ version: 1.0.0
+ ---
+ # My Skill
+ """;
+
+ var result = LintCommand.ValidateSkill("test-skill", "skills/test-skill/SKILL.md", content);
+
+ Assert.Empty(result.Issues);
+ Assert.Contains(result.Warnings, w => w.Contains("Missing recommended 'description'"));
+ }
+
+ [Theory]
+ [InlineData("1.0")]
+ [InlineData("v1.0.0")]
+ [InlineData("latest")]
+ public void ValidateSkill_NonStandardSemVer_Warning(string version)
+ {
+ var content = $"""
+ ---
+ name: test-skill
+ version: {version}
+ description: A test skill
+ ---
+ # My Skill
+ """;
+
+ var result = LintCommand.ValidateSkill("test-skill", "skills/test-skill/SKILL.md", content);
+
+ Assert.Contains(result.Warnings, w => w.Contains("does not follow semantic versioning"));
+ }
+
+ #endregion
+
+ #region Invalid name format
+
+ [Theory]
+ [InlineData("MySkill")]
+ [InlineData("test_skill")]
+ [InlineData("test skill")]
+ [InlineData("-test-skill")]
+ [InlineData("test.skill")]
+ public void ValidateSkill_InvalidNameFormat_Issue(string name)
+ {
+ var content = $"""
+ ---
+ name: {name}
+ version: 1.0.0
+ description: A test skill
+ ---
+ # My Skill
+ """;
+
+ var result = LintCommand.ValidateSkill(name, $"skills/{name}/SKILL.md", content);
+
+ Assert.Contains(result.Issues, i => i.Contains("Invalid 'name' format"));
+ }
+
+ #endregion
+
+ #region File system tests
+
+ [Fact]
+ public async Task ExecuteAsync_NonexistentDirectory_Returns1()
+ {
+ var args = new ParsedArgs { Positional = ["/nonexistent/path"] };
+
+ var result = await LintCommand.ExecuteAsync(args);
+
+ Assert.Equal(1, result);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_NoArguments_Returns1()
+ {
+ var args = new ParsedArgs { Positional = [] };
+
+ var result = await LintCommand.ExecuteAsync(args);
+
+ Assert.Equal(1, result);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_Help_Returns0()
+ {
+ var args = new ParsedArgs { Help = true };
+
+ var result = await LintCommand.ExecuteAsync(args);
+
+ Assert.Equal(0, result);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_ValidSkills_Returns0()
+ {
+ CreateSkillDirectory("good-skill", """
+ ---
+ name: good-skill
+ version: 1.0.0
+ description: A good skill
+ ---
+ # Good Skill
+ Content here.
+ """);
+
+ var args = new ParsedArgs { Positional = [_tempDir] };
+
+ var result = await LintCommand.ExecuteAsync(args);
+
+ Assert.Equal(0, result);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_BrokenSkills_Returns1()
+ {
+ CreateSkillDirectory("broken-skill", """
+ ---
+ name: broken-skill
+ description: Missing version
+ ---
+ # Broken Skill
+ """);
+
+ var args = new ParsedArgs { Positional = [_tempDir] };
+
+ var result = await LintCommand.ExecuteAsync(args);
+
+ Assert.Equal(1, result);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_MixedSkills_Returns1()
+ {
+ CreateSkillDirectory("good-skill", """
+ ---
+ name: good-skill
+ version: 1.0.0
+ description: A good skill
+ ---
+ # Good Skill
+ Content.
+ """);
+
+ CreateSkillDirectory("broken-skill", """
+ ---
+ name: broken-skill
+ description: Missing version
+ ---
+ # Broken Skill
+ """);
+
+ var args = new ParsedArgs { Positional = [_tempDir] };
+
+ var result = await LintCommand.ExecuteAsync(args);
+
+ Assert.Equal(1, result);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WarningsOnly_Returns0()
+ {
+ CreateSkillDirectory("warning-skill", """
+ ---
+ name: warning-skill
+ version: 1.0.0
+ ---
+ # Warning Skill
+ """);
+
+ var args = new ParsedArgs { Positional = [_tempDir] };
+
+ var result = await LintCommand.ExecuteAsync(args);
+
+ Assert.Equal(0, result);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_NoSkillMd_DirectoryWithFiles_Warning()
+ {
+ var dir = Path.Combine(_tempDir, "incomplete-skill");
+ Directory.CreateDirectory(dir);
+ File.WriteAllText(Path.Combine(dir, "README.md"), "# Readme");
+
+ var args = new ParsedArgs { Positional = [_tempDir] };
+
+ var result = await LintCommand.ExecuteAsync(args);
+
+ Assert.Equal(0, result); // warnings don't fail
+ }
+
+ #endregion
+}
diff --git a/tests/Netclaw.SkillServer.Cli.Tests/ProgramDispatchTests.cs b/tests/Netclaw.SkillServer.Cli.Tests/ProgramDispatchTests.cs
new file mode 100644
index 0000000..534d3b0
--- /dev/null
+++ b/tests/Netclaw.SkillServer.Cli.Tests/ProgramDispatchTests.cs
@@ -0,0 +1,89 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Petabridge, LLC
+//
+// -----------------------------------------------------------------------
+
+using System.Diagnostics;
+using Netclaw.SkillServer.Cli.Commands;
+using Xunit;
+
+namespace Netclaw.SkillServer.Cli.Tests;
+
+public sealed class ProgramDispatchTests : IDisposable
+{
+ private readonly string _tempDir;
+
+ public ProgramDispatchTests()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), $"skill-dispatch-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_tempDir);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_tempDir))
+ Directory.Delete(_tempDir, recursive: true);
+ }
+
+ [Fact]
+ public async Task Lint_DoesNotRequireServerUrl()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ var skillDir = Path.Combine(_tempDir, "example-skill");
+ Directory.CreateDirectory(skillDir);
+ await File.WriteAllTextAsync(Path.Combine(skillDir, "SKILL.md"),
+ "---\nname: example-skill\nversion: 1.0.0\ndescription: A skill.\n---\n\n# Example\n\nBody.",
+ ct);
+
+ var result = await RunCliAsync(["lint", _tempDir], ct);
+
+ Assert.Equal(0, result.ExitCode);
+ Assert.DoesNotContain("Server URL not configured", result.StdErr);
+ Assert.DoesNotContain("Server URL not configured", result.StdOut);
+ }
+
+ [Fact]
+ public async Task List_RequiresServerUrl()
+ {
+ var ct = TestContext.Current.CancellationToken;
+
+ var result = await RunCliAsync(["list"], ct);
+
+ Assert.Equal(1, result.ExitCode);
+ Assert.Contains("Server URL not configured", result.StdErr);
+ }
+
+ private static async Task RunCliAsync(string[] args, CancellationToken ct)
+ {
+ var dllPath = typeof(LintCommand).Assembly.Location;
+ var psi = new ProcessStartInfo("dotnet")
+ {
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+ psi.ArgumentList.Add("exec");
+ psi.ArgumentList.Add(dllPath);
+ foreach (var a in args)
+ psi.ArgumentList.Add(a);
+
+ // Isolate from the host environment: a developer or CI runner with these
+ // variables set must not mask the bug this test guards against.
+ psi.Environment.Remove("SKILLSERVER_URL");
+ psi.Environment.Remove("SKILLSERVER_API_KEY");
+
+ using var proc = Process.Start(psi)
+ ?? throw new InvalidOperationException("Failed to start CLI process");
+
+ var stdOutTask = proc.StandardOutput.ReadToEndAsync(ct);
+ var stdErrTask = proc.StandardError.ReadToEndAsync(ct);
+
+ await proc.WaitForExitAsync(ct).WaitAsync(TimeSpan.FromSeconds(30), ct);
+
+ return new CliResult(proc.ExitCode, await stdOutTask, await stdErrTask);
+ }
+
+ private sealed record CliResult(int ExitCode, string StdOut, string StdErr);
+}