Skip to content

Commit 34cff96

Browse files
Add CLI sub-agent workflows
Closes #91
1 parent 878c04e commit 34cff96

11 files changed

Lines changed: 1174 additions & 2 deletions

File tree

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// -----------------------------------------------------------------------
2+
// <copyright file="DownloadSubAgentCommand.cs" company="Petabridge, LLC">
3+
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
4+
// </copyright>
5+
// -----------------------------------------------------------------------
6+
7+
using Netclaw.SkillClient;
8+
using Netclaw.SkillServer.Cli.Output;
9+
using Netclaw.SkillServer.Cli.Publishing;
10+
11+
namespace Netclaw.SkillServer.Cli.Commands;
12+
13+
internal static class DownloadSubAgentCommand
14+
{
15+
public static async Task<int> ExecuteAsync(ParsedArgs args, SkillServerClient client)
16+
{
17+
if (args.Help || args.Positional.Count < 3)
18+
{
19+
PrintHelp();
20+
return args.Help ? 0 : 1;
21+
}
22+
23+
var name = args.Positional[0];
24+
var version = args.Positional[1];
25+
var destinationPath = Path.GetFullPath(args.Positional[2]);
26+
27+
if (!SubAgentFileScanner.IsValidName(name))
28+
{
29+
ConsoleOutput.WriteError("Error: Invalid sub-agent name.");
30+
return 1;
31+
}
32+
33+
if (!SubAgentFileScanner.IsValidVersion(version))
34+
{
35+
ConsoleOutput.WriteError("Error: Invalid sub-agent version.");
36+
return 1;
37+
}
38+
39+
if (File.Exists(destinationPath) && !args.Force)
40+
{
41+
ConsoleOutput.WriteError($"Error: Destination '{destinationPath}' already exists. Use --force to overwrite.");
42+
return 1;
43+
}
44+
45+
if (args.DryRun)
46+
{
47+
ConsoleOutput.WriteWarning(
48+
$"Dry run: would download sub-agent {name}@{version} to {destinationPath}");
49+
return 0;
50+
}
51+
52+
try
53+
{
54+
var detail = await client.GetNativeSubAgentVersionAsync(name, version);
55+
if (detail is null)
56+
{
57+
ConsoleOutput.WriteError($"Error: Sub-agent {name}@{version} not found.");
58+
return 1;
59+
}
60+
61+
if (!detail.Type.Equals(SubAgentArtifactTypes.AgentMd, StringComparison.OrdinalIgnoreCase))
62+
{
63+
ConsoleOutput.WriteError($"Error: Unsupported sub-agent artifact type '{detail.Type}'.");
64+
return 1;
65+
}
66+
67+
var parentDirectory = Path.GetDirectoryName(destinationPath);
68+
if (!string.IsNullOrEmpty(parentDirectory))
69+
Directory.CreateDirectory(parentDirectory);
70+
71+
var tempPath = $"{destinationPath}.tmp-{Guid.NewGuid():N}";
72+
try
73+
{
74+
await using (var destination = File.Create(tempPath))
75+
{
76+
if (args.Verbose)
77+
ConsoleOutput.WriteDim($" Downloading {detail.Url}");
78+
79+
var download = await client.DownloadNativeSubAgentArtifactAsync(detail, destination);
80+
if (args.Verbose)
81+
ConsoleOutput.WriteDim($" Verified {download.Digest} ({FormatSize(download.SizeBytes)})");
82+
}
83+
84+
File.Move(tempPath, destinationPath, overwrite: true);
85+
}
86+
finally
87+
{
88+
if (File.Exists(tempPath))
89+
File.Delete(tempPath);
90+
}
91+
92+
ConsoleOutput.WriteSuccess($"Downloaded sub-agent {name}@{version} to {destinationPath}");
93+
return 0;
94+
}
95+
catch (HttpRequestException ex)
96+
{
97+
return ConsoleOutput.HandleHttpError(ex);
98+
}
99+
catch (InvalidDataException ex)
100+
{
101+
ConsoleOutput.WriteError($"Error: {ex.Message}");
102+
return 1;
103+
}
104+
}
105+
106+
private static string FormatSize(long bytes) => bytes switch
107+
{
108+
< 1024 => $"{bytes} B",
109+
< 1024 * 1024 => $"{bytes / 1024.0:F1} KB",
110+
_ => $"{bytes / (1024.0 * 1024.0):F1} MB"
111+
};
112+
113+
private static void PrintHelp()
114+
{
115+
Console.WriteLine("Usage: skillserver download-subagent <name> <version> <path> [options]");
116+
Console.WriteLine();
117+
Console.WriteLine("Download a sub-agent artifact through the native manifest and verify its digest.");
118+
Console.WriteLine();
119+
Console.WriteLine("Arguments:");
120+
Console.WriteLine(" <name> Sub-agent name");
121+
Console.WriteLine(" <version> Sub-agent version");
122+
Console.WriteLine(" <path> Caller-controlled destination path");
123+
Console.WriteLine();
124+
Console.WriteLine("Options:");
125+
Console.WriteLine(" --force, -f Overwrite the destination path if it exists");
126+
Console.WriteLine(" --dry-run Show what would be downloaded without writing a file");
127+
Console.WriteLine(" --verbose, -v Show download URL and verified digest");
128+
}
129+
}

src/Netclaw.SkillServer.Cli/Commands/LintCommand.cs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ internal static class LintCommand
2424
{
2525
public static async Task<int> ExecuteAsync(ParsedArgs args)
2626
{
27+
if (args.Positional.Count > 0 &&
28+
args.Positional[0].Equals("subagent", StringComparison.OrdinalIgnoreCase))
29+
{
30+
return ExecuteSubAgentLint(args);
31+
}
32+
2733
if (args.Help || args.Positional.Count == 0)
2834
{
2935
PrintHelp();
@@ -107,6 +113,38 @@ public static async Task<int> ExecuteAsync(ParsedArgs args)
107113
return hasErrors ? 1 : 0;
108114
}
109115

116+
private static int ExecuteSubAgentLint(ParsedArgs args)
117+
{
118+
if (args.Help || args.Positional.Count < 2)
119+
{
120+
PrintSubAgentHelp();
121+
return args.Help ? 0 : 1;
122+
}
123+
124+
var path = args.Positional[1];
125+
var result = SubAgentFileScanner.ValidateFile(path);
126+
127+
ConsoleOutput.WriteInfo($"Linting sub-agent '{path}'...");
128+
Console.WriteLine();
129+
130+
foreach (var issue in result.Issues)
131+
ConsoleOutput.WriteError($"✗ {issue}");
132+
133+
foreach (var warning in result.Warnings)
134+
ConsoleOutput.WriteWarning($"⚠ {warning}");
135+
136+
Console.WriteLine();
137+
138+
if (result.Issues.Count == 0)
139+
{
140+
ConsoleOutput.WriteSuccess($"Sub-agent valid ({result.Warnings.Count} warning(s))");
141+
return 0;
142+
}
143+
144+
ConsoleOutput.WriteError($"{result.Issues.Count} error(s), {result.Warnings.Count} warning(s) — lint failed");
145+
return 1;
146+
}
147+
110148
/// <summary>
111149
/// Validate a single skill's SKILL.md content against the AgentSkills.io spec.
112150
/// </summary>
@@ -207,4 +245,21 @@ private static void PrintHelp()
207245
Console.WriteLine(" - Semantic version format");
208246
Console.WriteLine(" - Description present (warning if missing)");
209247
}
248+
249+
private static void PrintSubAgentHelp()
250+
{
251+
Console.WriteLine("Usage: skillserver lint subagent <path>");
252+
Console.WriteLine();
253+
Console.WriteLine("Validate a NetClaw-compatible sub-agent markdown file.");
254+
Console.WriteLine();
255+
Console.WriteLine("Arguments:");
256+
Console.WriteLine(" <path> Path to agent.md or another .md sub-agent definition");
257+
Console.WriteLine();
258+
Console.WriteLine("Validates:");
259+
Console.WriteLine(" - YAML frontmatter exists");
260+
Console.WriteLine(" - Required 'name' and 'description' fields are present");
261+
Console.WriteLine(" - Name format matches SkillServer resource names");
262+
Console.WriteLine(" - Prompt body is not empty");
263+
Console.WriteLine(" - modelRole, visibility, timeoutSeconds, and prefillTimeoutSeconds values are valid");
264+
}
210265
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// -----------------------------------------------------------------------
2+
// <copyright file="PublishSubAgentCommand.cs" company="Petabridge, LLC">
3+
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
4+
// </copyright>
5+
// -----------------------------------------------------------------------
6+
7+
using System.Net;
8+
using Netclaw.SkillClient;
9+
using Netclaw.SkillServer.Cli.Output;
10+
using Netclaw.SkillServer.Cli.Publishing;
11+
12+
namespace Netclaw.SkillServer.Cli.Commands;
13+
14+
internal static class PublishSubAgentCommand
15+
{
16+
public static async Task<int> ExecuteAsync(ParsedArgs args, SkillServerClient client)
17+
{
18+
if (args.Help || args.Positional.Count == 0)
19+
{
20+
PrintHelp();
21+
return args.Help ? 0 : 1;
22+
}
23+
24+
if (!SubAgentFileScanner.IsValidVersion(args.VersionOverride))
25+
{
26+
ConsoleOutput.WriteError("Error: --version <version> is required for sub-agent publication.");
27+
return 1;
28+
}
29+
30+
var result = SubAgentFileScanner.ValidateFile(args.Positional[0]);
31+
foreach (var warning in result.Warnings)
32+
ConsoleOutput.WriteWarning($"Warning: {warning}");
33+
34+
if (result.Issues.Count > 0)
35+
{
36+
foreach (var issue in result.Issues)
37+
ConsoleOutput.WriteError($"Error: {issue}");
38+
return 1;
39+
}
40+
41+
var subAgent = result.SubAgent!;
42+
var version = args.VersionOverride!;
43+
44+
if (args.DryRun)
45+
{
46+
ConsoleOutput.WriteWarning(
47+
$"Dry run: would publish sub-agent {subAgent.Name}@{version} from {subAgent.FilePath}");
48+
return 0;
49+
}
50+
51+
ConsoleOutput.WriteInfo($"Publishing sub-agent {subAgent.Name}@{version}...");
52+
53+
if (args.Force)
54+
{
55+
await DeleteExistingAsync(client, subAgent.Name, version, args.Verbose);
56+
}
57+
58+
try
59+
{
60+
await using var stream = File.OpenRead(subAgent.FilePath);
61+
if (args.Verbose)
62+
ConsoleOutput.WriteDim($" Uploading agent.md ({FormatSize(stream.Length)})");
63+
64+
var response = await client.UploadSubAgentIfNotExistsAsync(subAgent.Name, version, stream);
65+
if (response is null)
66+
{
67+
ConsoleOutput.WriteWarning($"Skipped sub-agent {subAgent.Name}@{version} (Already published)");
68+
return 0;
69+
}
70+
71+
ConsoleOutput.WriteSuccess($"Published sub-agent {response.Name}@{response.Version}");
72+
ConsoleOutput.WriteDim($" {response.Sha256}");
73+
ConsoleOutput.WriteDim($" {response.Url}");
74+
return 0;
75+
}
76+
catch (HttpRequestException ex)
77+
{
78+
return ConsoleOutput.HandleHttpError(ex);
79+
}
80+
}
81+
82+
private static async Task DeleteExistingAsync(
83+
SkillServerClient client,
84+
string name,
85+
string version,
86+
bool verbose)
87+
{
88+
try
89+
{
90+
await client.DeleteSubAgentVersionAsync(name, version);
91+
if (verbose)
92+
ConsoleOutput.WriteDim($" Deleted existing sub-agent {name}@{version}");
93+
}
94+
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
95+
{
96+
if (verbose)
97+
ConsoleOutput.WriteDim($" No existing sub-agent version to delete for {name}@{version}");
98+
}
99+
}
100+
101+
private static string FormatSize(long bytes) => bytes switch
102+
{
103+
< 1024 => $"{bytes} B",
104+
< 1024 * 1024 => $"{bytes / 1024.0:F1} KB",
105+
_ => $"{bytes / (1024.0 * 1024.0):F1} MB"
106+
};
107+
108+
private static void PrintHelp()
109+
{
110+
Console.WriteLine("Usage: skillserver publish-subagent <path> --version <version> [options]");
111+
Console.WriteLine();
112+
Console.WriteLine("Publish a single NetClaw-compatible sub-agent markdown file to the server.");
113+
Console.WriteLine();
114+
Console.WriteLine("Arguments:");
115+
Console.WriteLine(" <path> Path to agent.md or another .md sub-agent definition");
116+
Console.WriteLine();
117+
Console.WriteLine("Options:");
118+
Console.WriteLine(" --version <version> SkillServer publication version (required)");
119+
Console.WriteLine(" --force, -f Delete existing version before re-publishing");
120+
Console.WriteLine(" --dry-run Validate and show what would be published without uploading");
121+
Console.WriteLine(" --verbose, -v Show detailed upload progress");
122+
}
123+
}

src/Netclaw.SkillServer.Cli/Program.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
var resolver = new ConfigResolver();
5454
var config = resolver.Resolve(parsedArgs.ServerUrl, parsedArgs.ApiKey);
5555

56-
var requiresAuth = parsedArgs.Command is not "list" and not "versions" and not "verify";
56+
var requiresAuth = parsedArgs.Command is not "list" and not "versions" and not "verify" and not "download-subagent";
5757

5858
if (!config.HasServerUrl)
5959
{
@@ -77,6 +77,8 @@ static async Task<int> DispatchAsync(ParsedArgs parsedArgs, SkillServerClient cl
7777
parsedArgs.Command switch
7878
{
7979
"publish" => await PublishCommand.ExecuteAsync(parsedArgs, client),
80+
"publish-subagent" => await PublishSubAgentCommand.ExecuteAsync(parsedArgs, client),
81+
"download-subagent" => await DownloadSubAgentCommand.ExecuteAsync(parsedArgs, client),
8082
"publish-all" => await PublishAllCommand.ExecuteAsync(parsedArgs, client),
8183
"delete" => await DeleteCommand.ExecuteAsync(parsedArgs, client),
8284
"list" => await ListCommand.ExecuteAsync(parsedArgs, client),
@@ -118,8 +120,10 @@ static void PrintHelp()
118120
Console.WriteLine();
119121
Console.WriteLine("Commands:");
120122
Console.WriteLine(" publish <path> Publish a skill directory to the server");
123+
Console.WriteLine(" publish-subagent <path> Publish a sub-agent markdown file to the server");
124+
Console.WriteLine(" download-subagent <n> <v> <path> Download a verified sub-agent artifact");
121125
Console.WriteLine(" publish-all <path> Batch-publish all skills in a directory");
122-
Console.WriteLine(" lint <path> Validate skills against spec (no auth required)");
126+
Console.WriteLine(" lint <path> Validate skills or sub-agents (no auth required)");
123127
Console.WriteLine(" delete <name> <version> Delete a published skill version");
124128
Console.WriteLine(" list List skills on the server");
125129
Console.WriteLine(" versions <name> List all versions of a skill");

0 commit comments

Comments
 (0)