From b2ba7b4ff1c2f8a94e6aeedf5e00a63e87bfb2c2 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 15 May 2026 00:18:42 +0000 Subject: [PATCH 1/2] feat: add lint command to validate skills against spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 'skillserver lint ' 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. --- .../Commands/LintCommand.cs | 210 ++++++++++ src/Netclaw.SkillServer.Cli/Program.cs | 4 +- .../LintCommandTests.cs | 396 ++++++++++++++++++ 3 files changed, 609 insertions(+), 1 deletion(-) create mode 100644 src/Netclaw.SkillServer.Cli/Commands/LintCommand.cs create mode 100644 tests/Netclaw.SkillServer.Cli.Tests/LintCommandTests.cs 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..be4c50d 100644 --- a/src/Netclaw.SkillServer.Cli/Program.cs +++ b/src/Netclaw.SkillServer.Cli/Program.cs @@ -49,7 +49,7 @@ var resolver = new ConfigResolver(); var config = resolver.Resolve(parsedArgs.ServerUrl, parsedArgs.ApiKey); -var requiresAuth = parsedArgs.Command is not "list" and not "versions" and not "verify"; +var requiresAuth = parsedArgs.Command is not "list" and not "versions" and not "verify" and not "lint"; if (!config.HasServerUrl) { @@ -78,6 +78,7 @@ static async Task DispatchAsync(ParsedArgs parsedArgs, SkillServerClient cl "list" => await ListCommand.ExecuteAsync(parsedArgs, client), "versions" => await VersionsCommand.ExecuteAsync(parsedArgs, client), "verify" => await VerifyCommand.ExecuteAsync(parsedArgs, client), + "lint" => await LintCommand.ExecuteAsync(parsedArgs), "api-key" => await ApiKeyCommand.ExecuteAsync(parsedArgs, client), _ => UnknownCommand(parsedArgs.Command) }; @@ -115,6 +116,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 +} From 4a7bae95f46eefdddf2a6d714ca433b94249edb1 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 15 May 2026 02:31:44 +0000 Subject: [PATCH 2/2] 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. --- src/Netclaw.SkillServer.Cli/Program.cs | 7 +- .../ProgramDispatchTests.cs | 89 +++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 tests/Netclaw.SkillServer.Cli.Tests/ProgramDispatchTests.cs diff --git a/src/Netclaw.SkillServer.Cli/Program.cs b/src/Netclaw.SkillServer.Cli/Program.cs index be4c50d..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) { @@ -49,7 +53,7 @@ var resolver = new ConfigResolver(); var config = resolver.Resolve(parsedArgs.ServerUrl, parsedArgs.ApiKey); -var requiresAuth = parsedArgs.Command is not "list" and not "versions" and not "verify" and not "lint"; +var requiresAuth = parsedArgs.Command is not "list" and not "versions" and not "verify"; if (!config.HasServerUrl) { @@ -78,7 +82,6 @@ static async Task DispatchAsync(ParsedArgs parsedArgs, SkillServerClient cl "list" => await ListCommand.ExecuteAsync(parsedArgs, client), "versions" => await VersionsCommand.ExecuteAsync(parsedArgs, client), "verify" => await VerifyCommand.ExecuteAsync(parsedArgs, client), - "lint" => await LintCommand.ExecuteAsync(parsedArgs), "api-key" => await ApiKeyCommand.ExecuteAsync(parsedArgs, client), _ => UnknownCommand(parsedArgs.Command) }; 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); +}