Skip to content

Commit eb633c8

Browse files
feat: add lint command to validate skills against spec (#68)
* feat: add lint command to validate skills against spec Adds a 'skillserver lint <path>' command that validates skill directories against the AgentSkills.io specification without requiring server auth. Checks: - YAML frontmatter exists - Required 'name' and 'version' fields present - Name format (lowercase alphanumeric with hyphens) - Name matches directory name (warning) - Semantic version format (warning) - Description present (warning) - Body content after frontmatter (warning) Returns exit code 1 on errors, 0 on warnings-only — suitable for CI. Motivated by a real bug where 3 skills were silently skipped during publish-all because they were missing the required 'version' field. Includes 34 unit tests covering validation logic and CLI integration. * fix: exempt lint command from server URL requirement The lint command operates entirely on local files and was already exempted from the auth check, but the unconditional SKILLSERVER_URL check ran first and caused failures in CI environments that do not configure a server URL (which is the intended use case for lint). Dispatches 'lint' alongside 'config' before config resolution so it behaves consistently with the --help text ("no auth required"). Removes the now-unreachable switch arm in DispatchAsync. Adds a subprocess-level regression test that runs the CLI binary with SKILLSERVER_URL/API_KEY cleared from the environment, asserting that 'lint' succeeds with exit 0 and 'list' still fails with the expected error.
1 parent ebc4a5f commit eb633c8

4 files changed

Lines changed: 700 additions & 0 deletions

File tree

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
// -----------------------------------------------------------------------
2+
// <copyright file="LintCommand.cs" company="Petabridge, LLC">
3+
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
4+
// </copyright>
5+
// -----------------------------------------------------------------------
6+
7+
using Netclaw.SkillServer.Cli.Output;
8+
using Netclaw.SkillServer.Cli.Publishing;
9+
10+
namespace Netclaw.SkillServer.Cli.Commands;
11+
12+
/// <summary>
13+
/// Result of validating a single skill directory.
14+
/// </summary>
15+
public sealed record SkillLintResult(
16+
IReadOnlyList<string> Issues,
17+
IReadOnlyList<string> Warnings);
18+
19+
/// <summary>
20+
/// Lint command: validates skill directories against the AgentSkills.io spec
21+
/// and reports issues. Used in CI to catch problems before publishing.
22+
/// </summary>
23+
internal static class LintCommand
24+
{
25+
public static async Task<int> ExecuteAsync(ParsedArgs args)
26+
{
27+
if (args.Help || args.Positional.Count == 0)
28+
{
29+
PrintHelp();
30+
return args.Help ? 0 : 1;
31+
}
32+
33+
var path = args.Positional[0];
34+
if (!Directory.Exists(path))
35+
{
36+
ConsoleOutput.WriteError($"Error: Directory '{path}' does not exist.");
37+
return 1;
38+
}
39+
40+
var dir = Path.GetFullPath(path);
41+
var issues = new List<string>();
42+
var warnings = new List<string>();
43+
var validated = 0;
44+
45+
// Scan each subdirectory for a skill
46+
var subDirs = Directory.EnumerateDirectories(dir)
47+
.OrderBy(d => d, StringComparer.OrdinalIgnoreCase)
48+
.ToList();
49+
50+
foreach (var subDir in subDirs)
51+
{
52+
var skillName = Path.GetFileName(subDir);
53+
var skillMdPath = Path.Combine(subDir, "SKILL.md");
54+
55+
if (!File.Exists(skillMdPath))
56+
{
57+
// Check if this looks like it should be a skill (has files but no SKILL.md)
58+
var hasMd = Directory.GetFiles(subDir, "*.md", SearchOption.TopDirectoryOnly)
59+
.Any(f => Path.GetFileName(f).ToLowerInvariant() != "readme.md");
60+
var hasSubdirs = Directory.GetDirectories(subDir).Any();
61+
62+
if (hasMd || hasSubdirs)
63+
{
64+
warnings.Add($"{skillName}: No SKILL.md found — directory may be incomplete");
65+
}
66+
continue;
67+
}
68+
69+
var content = File.ReadAllText(skillMdPath);
70+
var skillIssues = ValidateSkill(skillName, skillMdPath, content);
71+
issues.AddRange(skillIssues.Issues);
72+
warnings.AddRange(skillIssues.Warnings);
73+
74+
if (skillIssues.Issues.Count == 0)
75+
validated++;
76+
}
77+
78+
// Print results
79+
ConsoleOutput.WriteInfo($"Linting '{path}'...");
80+
ConsoleOutput.WriteInfo($"Found {subDirs.Count} potential skill directory(ies).");
81+
Console.WriteLine();
82+
83+
bool hasErrors = false;
84+
85+
foreach (var issue in issues)
86+
{
87+
ConsoleOutput.WriteError($"✗ {issue}");
88+
hasErrors = true;
89+
}
90+
91+
foreach (var warning in warnings)
92+
{
93+
ConsoleOutput.WriteWarning($"⚠ {warning}");
94+
}
95+
96+
Console.WriteLine();
97+
98+
if (!hasErrors)
99+
{
100+
ConsoleOutput.WriteSuccess($"All skills valid ({validated} passed, {warnings.Count} warning(s))");
101+
}
102+
else
103+
{
104+
ConsoleOutput.WriteError($"{issues.Count} error(s), {warnings.Count} warning(s) — lint failed");
105+
}
106+
107+
return hasErrors ? 1 : 0;
108+
}
109+
110+
/// <summary>
111+
/// Validate a single skill's SKILL.md content against the AgentSkills.io spec.
112+
/// </summary>
113+
internal static SkillLintResult ValidateSkill(
114+
string skillName, string skillMdPath, string content)
115+
{
116+
var issues = new List<string>();
117+
var warnings = new List<string>();
118+
119+
// 1. Check frontmatter exists
120+
var frontmatter = SkillDirectoryScanner.ParseFrontmatter(content);
121+
if (frontmatter is null)
122+
{
123+
issues.Add($"{skillName}: No YAML frontmatter found in SKILL.md");
124+
return new SkillLintResult(issues, warnings);
125+
}
126+
127+
// 2. Required: name
128+
var name = frontmatter.GetValueOrDefault("name");
129+
if (string.IsNullOrWhiteSpace(name))
130+
{
131+
issues.Add($"{skillName}: Missing required 'name' field in frontmatter");
132+
}
133+
else
134+
{
135+
// Check name matches directory name
136+
var dirName = Path.GetFileName(Path.GetDirectoryName(skillMdPath)!);
137+
if (!string.Equals(name, dirName, StringComparison.OrdinalIgnoreCase))
138+
{
139+
warnings.Add($"{skillName}: Frontmatter 'name' ({name}) does not match directory name ({dirName})");
140+
}
141+
142+
// Validate name format (lowercase alphanumeric and hyphens)
143+
if (!System.Text.RegularExpressions.Regex.IsMatch(name, @"^[a-z0-9][a-z0-9\-]*$"))
144+
{
145+
issues.Add($"{skillName}: Invalid 'name' format '{name}' — must be lowercase alphanumeric with hyphens");
146+
}
147+
}
148+
149+
// 3. Required: version
150+
var version = frontmatter.GetValueOrDefault("version");
151+
if (string.IsNullOrWhiteSpace(version))
152+
{
153+
issues.Add($"{skillName}: Missing required 'version' field in frontmatter");
154+
}
155+
else
156+
{
157+
// Validate semantic version format (basic check)
158+
if (!System.Text.RegularExpressions.Regex.IsMatch(version, @"^\d+\.\d+\.\d+"))
159+
{
160+
warnings.Add($"{skillName}: 'version' '{version}' does not follow semantic versioning (x.y.z)");
161+
}
162+
}
163+
164+
// 4. Recommended: description
165+
var description = frontmatter.GetValueOrDefault("description");
166+
if (string.IsNullOrWhiteSpace(description))
167+
{
168+
warnings.Add($"{skillName}: Missing recommended 'description' field in frontmatter");
169+
}
170+
171+
// 5. Check body has content after frontmatter (find the CLOSING ---, not the opening)
172+
var firstDash = content.IndexOf("---\n", StringComparison.Ordinal);
173+
if (firstDash >= 0)
174+
{
175+
var secondDash = content.IndexOf("---\n", firstDash + 4, StringComparison.Ordinal);
176+
if (secondDash >= 0)
177+
{
178+
var body = content[(secondDash + 4)..].Trim();
179+
if (string.IsNullOrEmpty(body))
180+
{
181+
warnings.Add($"{skillName}: SKILL.md has no body content after frontmatter");
182+
}
183+
}
184+
}
185+
186+
return new SkillLintResult(issues, warnings);
187+
}
188+
189+
private static void PrintHelp()
190+
{
191+
Console.WriteLine("Usage: skillserver lint <path>");
192+
Console.WriteLine();
193+
Console.WriteLine("Validate all skills in a directory against the AgentSkills.io specification.");
194+
Console.WriteLine("Checks for required fields, format issues, and common problems.");
195+
Console.WriteLine();
196+
Console.WriteLine("Returns exit code 1 if any errors are found (suitable for CI).");
197+
Console.WriteLine();
198+
Console.WriteLine("Arguments:");
199+
Console.WriteLine(" <path> Parent directory containing skill subdirectories");
200+
Console.WriteLine();
201+
Console.WriteLine("Validates:");
202+
Console.WriteLine(" - YAML frontmatter exists");
203+
Console.WriteLine(" - Required 'name' field present");
204+
Console.WriteLine(" - Required 'version' field present");
205+
Console.WriteLine(" - Name matches directory name");
206+
Console.WriteLine(" - Name format (lowercase alphanumeric with hyphens)");
207+
Console.WriteLine(" - Semantic version format");
208+
Console.WriteLine(" - Description present (warning if missing)");
209+
}
210+
}

src/Netclaw.SkillServer.Cli/Program.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@
3939
if (parsedArgs.Command == "config")
4040
return await ConfigCommand.ExecuteAsync(parsedArgs);
4141

42+
// lint operates entirely on local files — no server URL or auth needed
43+
if (parsedArgs.Command == "lint")
44+
return await LintCommand.ExecuteAsync(parsedArgs);
45+
4246
// --help on subcommands works without auth
4347
if (parsedArgs.Help)
4448
{
@@ -115,6 +119,7 @@ static void PrintHelp()
115119
Console.WriteLine("Commands:");
116120
Console.WriteLine(" publish <path> Publish a skill directory to the server");
117121
Console.WriteLine(" publish-all <path> Batch-publish all skills in a directory");
122+
Console.WriteLine(" lint <path> Validate skills against spec (no auth required)");
118123
Console.WriteLine(" delete <name> <version> Delete a published skill version");
119124
Console.WriteLine(" list List skills on the server");
120125
Console.WriteLine(" versions <name> List all versions of a skill");

0 commit comments

Comments
 (0)