forked from netclaw-dev/skill-server
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathLintCommand.cs
More file actions
210 lines (182 loc) · 7.68 KB
/
Copy pathLintCommand.cs
File metadata and controls
210 lines (182 loc) · 7.68 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
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)");
}
}