feat: Dynamic command loader, default command with module registry

This commit is contained in:
Aleksander Cynarski 2025-04-20 12:38:27 +02:00
parent a7cae86254
commit cea3d11a41
13 changed files with 373 additions and 3 deletions

1
.gitignore vendored
View File

@ -402,3 +402,4 @@ FodyWeavers.xsd
# End of https://www.toptal.com/developers/gitignore/api/csharp # End of https://www.toptal.com/developers/gitignore/api/csharp
.DS_Store .DS_Store
.vscode .vscode
.idea

View File

@ -8,7 +8,11 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Spectre.Console.Cli" Version="0.50.1-preview.0.5" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0-preview.3.25171.5" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0-preview.3.25171.5" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0-preview.3.25171.5" />
<PackageReference Include="Spectre.Console" Version="0.50.0" />
<PackageReference Include="Spectre.Console.Cli" Version="0.50.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -0,0 +1,26 @@
using Automancer.Command.Image;
using Automancer.Common;
using Spectre.Console.Cli;
namespace Automancer.Command.Container;
public class Module : ICommandModuleWithRegistry
{
public void Configure(IConfigurator config)
{
// No implementation needed here
}
public void Configure(IConfigurator config, CommandRegistry registry)
{
config.AddBranch("container", container =>
{
var description = "Container operations";
container.SetDescription(description);
registry.Add("container", description);
container.AddCommand<PsCommand>("ps").WithDescription("List containers");
});
}
}

View File

@ -0,0 +1,31 @@
using System.ComponentModel;
using Spectre.Console.Cli;
namespace Automancer.Command.Image;
public class PsSettings: CommandSettings
{
[CommandArgument(0, "[path]")]
[Description("Path to the Dockerfile")]
public string? Path { get; set; }
[CommandOption("-t|--tag")]
[Description("Tag to use for the image")]
public string? Tag { get; set; }
[CommandOption("-f|--file")]
[Description("Path to the Dockerfile")]
public string? File { get; set; }
}
public class PsCommand: Command<BuldSettings>
{
public override int Execute(CommandContext context, BuldSettings settings)
{
Console.WriteLine($"Building image from {settings.Path} with tag {settings.Tag}");
return 0;
}
}

View File

@ -0,0 +1,34 @@
using System.ComponentModel;
using Spectre.Console.Cli;
namespace Automancer.Command.Image;
public class BuldSettings: CommandSettings
{
[CommandArgument(0, "[path]")]
[Description("Path to the Dockerfile")]
public string? Path { get; set; }
[CommandOption("-t|--tag")]
[Description("Tag to use for the image")]
public string? Tag { get; set; }
[CommandOption("-f|--file")]
[Description("Path to the Dockerfile")]
public string? File { get; set; }
[CommandOption("--push")]
[Description("Push the image after building it")]
public bool Push { get; set; }
}
public class BuildCommand : Command<BuldSettings>
{
public override int Execute(CommandContext context, BuldSettings settings)
{
Console.WriteLine($"Building image from {settings.Path} with tag {settings.Tag}");
return 0;
}
}

25
Commands/Image/Module.cs Normal file
View File

@ -0,0 +1,25 @@
using Automancer.Common;
using Spectre.Console.Cli;
namespace Automancer.Command.Image;
public class Module : ICommandModuleWithRegistry
{
public void Configure(IConfigurator config)
{
// No implementation needed here
}
public void Configure(IConfigurator config, CommandRegistry registry)
{
config.AddBranch("image", image => {
var description = "Docker/podman image operations";
image.SetDescription(description);
registry.Add("image", description);
image.AddCommand<BuildCommand>("build").WithDescription("Build a docker image");
image.AddCommand<PushCommand>("push").WithDescription("Push a docker image");
});
}
}

View File

@ -0,0 +1,30 @@
using System.ComponentModel;
using Spectre.Console.Cli;
namespace Automancer.Command.Image;
public class PushSettings: CommandSettings
{
[CommandArgument(0, "[path]")]
[Description("Path to the Dockerfile")]
public string? Path { get; set; }
[CommandOption("-t|--tag")]
[Description("Tag to use for the image")]
public string? Tag { get; set; }
[CommandOption("-f|--file")]
[Description("Path to the Dockerfile")]
public string? File { get; set; }
}
public class PushCommand : Command<BuldSettings>
{
public override int Execute(CommandContext context, BuldSettings settings)
{
Console.WriteLine($"Building image from {settings.Path} with tag {settings.Tag}");
return 0;
}
}

38
Commands/RootCommand.cs Normal file
View File

@ -0,0 +1,38 @@
using Automancer.Common;
using Spectre.Console;
using Spectre.Console.Cli;
using System.Diagnostics.CodeAnalysis;
namespace Automancer.Commands;
public class RootCommand : Spectre.Console.Cli.Command
{
private readonly CommandRegistry _registry;
public RootCommand(CommandRegistry registry)
{
_registry = registry;
}
public override int Execute([NotNull] CommandContext context)
{
AnsiConsole.Write(
new FigletText("AUTOMANCER")
.Centered()
.Color(Color.Cyan1));
AnsiConsole.MarkupLine("[bold]Automancer[/] is a modular CLI automation tool. It is designed to be extensible and easy to use.");
AnsiConsole.MarkupLine("Use [green]--help[/] or [green]<command> --help[/] to get started.");
var table = new Table().Border(TableBorder.Rounded);
table.AddColumn("[bold yellow]Module[/]");
table.AddColumn("[bold yellow]Description[/]");
foreach (var cmd in _registry.Commands.OrderBy(c => c.Path))
{
table.AddRow($"[green]{cmd.Path}[/]", cmd.Description ?? "[dim]n/a[/]");
}
AnsiConsole.Write(table);
return 0;
}
}

13
Common/CommandRegistry.cs Normal file
View File

@ -0,0 +1,13 @@
namespace Automancer.Common;
public record CommandInfo(string Path, string? Description);
public class CommandRegistry
{
public List<CommandInfo> Commands { get; } = new();
public void Add(string path, string? description)
{
Commands.Add(new CommandInfo(path, description));
}
}

15
Common/ICommandModule.cs Normal file
View File

@ -0,0 +1,15 @@
namespace Automancer.Common;
using Spectre.Console.Cli;
public interface ICommandModule
{
void Configure(IConfigurator config);
}
public interface ICommandModuleWithRegistry : ICommandModule
{
void Configure(IConfigurator config, CommandRegistry registry);
}

34
Common/TypeRegistrar.cs Normal file
View File

@ -0,0 +1,34 @@
using Microsoft.Extensions.DependencyInjection;
using Spectre.Console.Cli;
namespace Automancer.Common;
public sealed class TypeRegistrar : ITypeRegistrar
{
private readonly IServiceCollection _builder;
public TypeRegistrar(IServiceCollection builder)
{
_builder = builder;
}
public ITypeResolver Build()
{
return new TypeResolver(_builder.BuildServiceProvider());
}
public void Register(Type service, Type implementation)
{
_builder.AddSingleton(service, implementation);
}
public void RegisterInstance(Type service, object implementation)
{
_builder.AddSingleton(service, implementation);
}
public void RegisterLazy(Type service, Func<object> factory)
{
_builder.AddSingleton(service, _ => factory());
}
}

24
Common/TypeResolver.cs Normal file
View File

@ -0,0 +1,24 @@
using Microsoft.Extensions.DependencyInjection;
using Spectre.Console.Cli;
namespace Automancer.Common;
public sealed class TypeResolver : ITypeResolver, IDisposable
{
private readonly ServiceProvider _provider;
public TypeResolver(ServiceProvider provider)
{
_provider = provider;
}
public object? Resolve(Type? type)
{
return type is null ? null : _provider.GetService(type);
}
public void Dispose()
{
_provider.Dispose();
}
}

View File

@ -1,2 +1,97 @@
// See https://aka.ms/new-console-template for more information namespace Automancer;
Console.WriteLine("Hello, World!");
using System;
using System.Linq;
using System.Threading.Tasks;
using Automancer.Common;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Spectre.Console.Cli;
using Spectre.Console;
using System.Diagnostics.CodeAnalysis;
using Automancer.Commands;
public abstract class Program
{
public static async Task<int> Main(string[] args)
{
CommandApp? app = null;
var host = Host.CreateDefaultBuilder(args)
.ConfigureLogging(logging =>
{
logging.ClearProviders();
logging.AddConsole();
logging.SetMinimumLevel(LogLevel.Information);
})
.ConfigureAppConfiguration((hostingContext, config) => {
var env = hostingContext.HostingEnvironment;
config.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
.AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true, reloadOnChange: true)
.AddEnvironmentVariables(prefix: "AUTOMANCER_")
.AddCommandLine(args);
})
.ConfigureServices((context, services) =>
{
var registry = new CommandRegistry();
services.AddSingleton(registry);
RegisterCommandModules(services);
var registrar = new TypeRegistrar(services);
app = new CommandApp(registrar);
app.SetDefaultCommand<RootCommand>();
var provider = services.BuildServiceProvider();
app.Configure(config =>
{
config.SetApplicationName("automancer");
var modules = provider.GetServices<ICommandModule>();
var registry = provider.GetRequiredService<CommandRegistry>();
foreach (var module in modules)
{
if (module is ICommandModuleWithRegistry withRegistry)
{
withRegistry.Configure(config, registry);
}
else
{
module.Configure(config);
}
}
});
})
.Build();
return app != null ? await app.RunAsync(args) : 1;
}
private static void RegisterCommandModules(IServiceCollection services)
{
var moduleType = typeof(ICommandModule);
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
var moduleTypes = assemblies
.SelectMany(a =>
{
try { return a.GetTypes(); }
catch { return []; }
})
.Where(t => moduleType.IsAssignableFrom(t) && !t.IsInterface && !t.IsAbstract)
.ToList();
foreach (var type in moduleTypes)
{
services.AddSingleton(type);
services.AddSingleton(typeof(ICommandModule), type);
}
}
}