McMaster.Extensions.CommandLineUtils

.NET Core/Standard向けのコマンドライン解析およびユーティリティライブラリ。

csharpclicommand-linedotnet-core

フレームワーク

McMaster.Extensions.CommandLineUtils

概要

McMaster.Extensions.CommandLineUtilsは、.NET Core/Standard向けのコマンドライン解析およびユーティリティライブラリです。ASP.NET Coreチームのメンバーによって開発され、.NET Coreエコシステムでの統合が優れています。モダンな.NETアプリケーションでの使いやすさと、豊富な機能を両立したライブラリです。

詳細

このライブラリは、.NET Coreの依存性注入、設定システム、ログ機能との統合を重視して設計されています。属性ベースとフルーエントAPIの両方をサポートし、開発者が好みに応じてアプローチを選択できます。特に.NET Coreの機能をフル活用したい場合に最適な選択肢です。

主な特徴

  • .NET Core統合: 依存性注入、設定、ログとの深い統合
  • 柔軟なAPI: 属性ベースとフルーエントAPIの両方をサポート
  • サブコマンド: 階層的なコマンド構造をサポート
  • バリデーション: 豊富なバリデーション機能
  • プロンプト: 対話的なユーザー入力プロンプト
  • 自動ヘルプ生成: 美しいヘルプテキストの自動生成
  • プラグインアーキテクチャ: 拡張可能な設計
  • 非同期サポート: async/awaitパターンの完全サポート

メリット・デメリット

メリット

  • .NET Core親和性: .NET Coreの機能との優れた統合
  • 柔軟な設計: 様々な開発スタイルに対応
  • モダンなAPI: 最新の.NET機能を活用
  • 充実したドキュメント: 詳細な公式ドキュメント
  • 活発な開発: 継続的な改善とアップデート
  • 依存性注入対応: DIコンテナとの統合が容易
  • テスト可能: 単体テストしやすい設計

デメリット

  • .NET Core依存: .NET Framework環境では制限がある
  • 学習コスト: 豊富な機能ゆえに習得に時間が必要
  • 相対的に新しい: 他のライブラリに比べて歴史が浅い
  • ドキュメント分散: 機能が多岐にわたるため情報が分散

主要リンク

書き方の例

基本的な属性ベースの例

using McMaster.Extensions.CommandLineUtils;
using System;
using System.ComponentModel.DataAnnotations;

[Command(Name = "myapp", Description = "サンプルアプリケーション")]
public class Program
{
    [Option("-n|--name", Description = "お名前")]
    [Required]
    public string Name { get; set; }

    [Option("-a|--age", Description = "年齢")]
    [Range(0, 150)]
    public int Age { get; set; } = 0;

    [Option("-v|--verbose", Description = "詳細出力")]
    public bool Verbose { get; set; }

    [Argument(0, Description = "処理するファイル")]
    public string[] Files { get; set; }

    static int Main(string[] args)
        => CommandLineApplication.Execute<Program>(args);

    private int OnExecute()
    {
        Console.WriteLine($"こんにちは、{Name}さん!");
        
        if (Age > 0)
        {
            Console.WriteLine($"年齢: {Age}歳");
        }

        if (Verbose)
        {
            Console.WriteLine("詳細モードが有効です");
        }

        if (Files?.Length > 0)
        {
            Console.WriteLine("処理ファイル:");
            foreach (var file in Files)
            {
                Console.WriteLine($"  - {file}");
            }
        }

        return 0;
    }
}

フルーエントAPIの例

using McMaster.Extensions.CommandLineUtils;
using System;

class Program
{
    static int Main(string[] args)
    {
        var app = new CommandLineApplication
        {
            Name = "filemanager",
            Description = "ファイル管理ツール"
        };

        app.HelpOption();

        var nameOption = app.Option("-n|--name <NAME>", "ユーザー名", CommandOptionType.SingleValue)
            .IsRequired();

        var verboseOption = app.Option("-v|--verbose", "詳細出力", CommandOptionType.NoValue);

        var filesArgument = app.Argument("files", "処理するファイル", multipleValues: true);

        app.OnExecute(() =>
        {
            Console.WriteLine($"ユーザー: {nameOption.Value()}");

            if (verboseOption.HasValue())
            {
                Console.WriteLine("詳細モードが有効です");
            }

            if (filesArgument.Values.Count > 0)
            {
                Console.WriteLine("ファイル一覧:");
                foreach (var file in filesArgument.Values)
                {
                    Console.WriteLine($"  - {file}");
                }
            }

            return 0;
        });

        return app.Execute(args);
    }
}

サブコマンドの例

using McMaster.Extensions.CommandLineUtils;
using System;
using System.ComponentModel.DataAnnotations;

[Command(Name = "git-tool", Description = "Git操作ツール")]
[Subcommand(typeof(CloneCommand))]
[Subcommand(typeof(PushCommand))]
[Subcommand(typeof(StatusCommand))]
public class GitToolProgram
{
    static int Main(string[] args)
        => CommandLineApplication.Execute<GitToolProgram>(args);

    private int OnExecute(CommandLineApplication app)
    {
        app.ShowHelp();
        return 1;
    }
}

[Command(Name = "clone", Description = "リポジトリをクローンします")]
public class CloneCommand
{
    [Argument(0, Description = "リポジトリURL")]
    [Required]
    public string Repository { get; set; }

    [Option("--depth <DEPTH>", Description = "履歴の深さ")]
    public int? Depth { get; set; }

    [Option("-b|--branch <BRANCH>", Description = "ブランチ名")]
    public string Branch { get; set; }

    [Option("-v|--verbose", Description = "詳細出力")]
    public bool Verbose { get; set; }

    private int OnExecute()
    {
        Console.WriteLine($"リポジトリをクローン中: {Repository}");

        if (!string.IsNullOrEmpty(Branch))
        {
            Console.WriteLine($"ブランチ: {Branch}");
        }

        if (Depth.HasValue)
        {
            Console.WriteLine($"履歴の深さ: {Depth.Value}");
        }

        if (Verbose)
        {
            Console.WriteLine("詳細モードで実行中...");
        }

        // 実際のクローン処理をここに実装

        return 0;
    }
}

[Command(Name = "push", Description = "変更をプッシュします")]
public class PushCommand
{
    [Argument(0, Description = "リモート名")]
    public string Remote { get; set; } = "origin";

    [Argument(1, Description = "ブランチ名")]
    public string Branch { get; set; } = "main";

    [Option("-f|--force", Description = "強制プッシュ")]
    public bool Force { get; set; }

    [Option("-u|--set-upstream", Description = "アップストリームを設定")]
    public bool SetUpstream { get; set; }

    private int OnExecute()
    {
        Console.WriteLine($"プッシュ中: {Remote}/{Branch}");

        if (Force)
        {
            Console.WriteLine("強制プッシュが有効です");
        }

        if (SetUpstream)
        {
            Console.WriteLine("アップストリームを設定します");
        }

        // 実際のプッシュ処理をここに実装

        return 0;
    }
}

[Command(Name = "status", Description = "リポジトリの状態を表示します")]
public class StatusCommand
{
    [Option("-s|--short", Description = "短縮形式で表示")]
    public bool Short { get; set; }

    [Option("--porcelain", Description = "スクリプト用出力")]
    public bool Porcelain { get; set; }

    private int OnExecute()
    {
        Console.WriteLine("リポジトリ状態:");

        if (Short)
        {
            Console.WriteLine("M  modified_file.txt");
            Console.WriteLine("A  new_file.txt");
            Console.WriteLine("?? untracked_file.txt");
        }
        else if (Porcelain)
        {
            Console.WriteLine("M modified_file.txt");
            Console.WriteLine("A new_file.txt");
            Console.WriteLine("?? untracked_file.txt");
        }
        else
        {
            Console.WriteLine("On branch main");
            Console.WriteLine("Changes to be committed:");
            Console.WriteLine("  (use \"git reset HEAD <file>...\" to unstage)");
            Console.WriteLine();
            Console.WriteLine("\tnew file:   new_file.txt");
            Console.WriteLine();
            Console.WriteLine("Changes not staged for commit:");
            Console.WriteLine("  (use \"git add <file>...\" to update what will be committed)");
            Console.WriteLine();
            Console.WriteLine("\tmodified:   modified_file.txt");
            Console.WriteLine();
            Console.WriteLine("Untracked files:");
            Console.WriteLine("  (use \"git add <file>...\" to include in what will be committed)");
            Console.WriteLine();
            Console.WriteLine("\tuntracked_file.txt");
        }

        return 0;
    }
}

依存性注入との統合例

using McMaster.Extensions.CommandLineUtils;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System;
using System.Threading.Tasks;

// サービスインターフェース
public interface IDataService
{
    Task ProcessDataAsync(string[] files);
}

public class DataService : IDataService
{
    private readonly ILogger<DataService> _logger;

    public DataService(ILogger<DataService> logger)
    {
        _logger = logger;
    }

    public async Task ProcessDataAsync(string[] files)
    {
        _logger.LogInformation("データ処理を開始します");

        foreach (var file in files)
        {
            _logger.LogInformation($"処理中: {file}");
            await Task.Delay(500); // 処理をシミュレート
        }

        _logger.LogInformation("データ処理が完了しました");
    }
}

[Command(Name = "processor", Description = "データ処理アプリケーション")]
public class ProcessorCommand
{
    private readonly IDataService _dataService;
    private readonly ILogger<ProcessorCommand> _logger;

    public ProcessorCommand(IDataService dataService, ILogger<ProcessorCommand> logger)
    {
        _dataService = dataService;
        _logger = logger;
    }

    [Argument(0, Description = "処理するファイル")]
    public string[] Files { get; set; }

    [Option("-p|--parallel", Description = "並列処理")]
    public bool Parallel { get; set; }

    [Option("-v|--verbose", Description = "詳細ログ")]
    public bool Verbose { get; set; }

    private async Task<int> OnExecuteAsync()
    {
        if (Files?.Length == 0)
        {
            _logger.LogError("処理するファイルが指定されていません");
            return 1;
        }

        _logger.LogInformation($"処理モード: {(Parallel ? "並列" : "順次")}");

        try
        {
            await _dataService.ProcessDataAsync(Files);
            return 0;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "処理中にエラーが発生しました");
            return 1;
        }
    }
}

class Program
{
    static async Task<int> Main(string[] args)
    {
        var host = Host.CreateDefaultBuilder(args)
            .ConfigureServices((context, services) =>
            {
                services.AddScoped<IDataService, DataService>();
            })
            .ConfigureLogging((context, logging) =>
            {
                logging.AddConsole();
                logging.SetMinimumLevel(LogLevel.Information);
            })
            .Build();

        return await host.Services
            .GetRequiredService<CommandLineApplication<ProcessorCommand>>()
            .ExecuteAsync(args);
    }
}

プロンプトと対話的入力の例

using McMaster.Extensions.CommandLineUtils;
using System;
using System.ComponentModel.DataAnnotations;

[Command(Name = "setup", Description = "アプリケーション設定ツール")]
public class SetupCommand
{
    [Option("-i|--interactive", Description = "対話モード")]
    public bool Interactive { get; set; }

    [Option("--name <NAME>", Description = "アプリケーション名")]
    public string AppName { get; set; }

    [Option("--port <PORT>", Description = "ポート番号")]
    public int? Port { get; set; }

    [Option("--database <URL>", Description = "データベースURL")]
    public string DatabaseUrl { get; set; }

    static int Main(string[] args)
        => CommandLineApplication.Execute<SetupCommand>(args);

    private int OnExecute(IConsole console)
    {
        if (Interactive)
        {
            return RunInteractiveSetup(console);
        }
        else
        {
            return RunNonInteractiveSetup(console);
        }
    }

    private int RunInteractiveSetup(IConsole console)
    {
        console.WriteLine("対話式セットアップを開始します...");
        console.WriteLine();

        // アプリケーション名の入力
        if (string.IsNullOrEmpty(AppName))
        {
            AppName = Prompt.GetString("アプリケーション名を入力してください:", defaultValue: "MyApp");
        }

        // ポート番号の入力
        if (!Port.HasValue)
        {
            Port = Prompt.GetInt("ポート番号を入力してください:", defaultValue: 8080);
        }

        // データベースURLの入力
        if (string.IsNullOrEmpty(DatabaseUrl))
        {
            DatabaseUrl = Prompt.GetString("データベースURLを入力してください:", defaultValue: "sqlite:///app.db");
        }

        // パスワードの入力(マスク)
        var adminPassword = Prompt.GetPassword("管理者パスワードを設定してください:");

        // 確認
        var confirm = Prompt.GetYesNo("設定を保存しますか?", defaultAnswer: true);

        if (confirm)
        {
            SaveConfiguration(console);
            return 0;
        }
        else
        {
            console.WriteLine("設定はキャンセルされました。");
            return 1;
        }
    }

    private int RunNonInteractiveSetup(IConsole console)
    {
        if (string.IsNullOrEmpty(AppName) || !Port.HasValue || string.IsNullOrEmpty(DatabaseUrl))
        {
            console.Error.WriteLine("非対話モードでは、すべてのオプションが必要です。");
            console.Error.WriteLine("--name, --port, --database を指定してください。");
            return 1;
        }

        SaveConfiguration(console);
        return 0;
    }

    private void SaveConfiguration(IConsole console)
    {
        console.WriteLine("設定を保存中...");
        console.WriteLine($"アプリケーション名: {AppName}");
        console.WriteLine($"ポート: {Port}");
        console.WriteLine($"データベース: {DatabaseUrl}");

        // 実際の保存処理をここに実装
        
        console.WriteLine("設定が正常に保存されました。");
    }
}

カスタムバリデーションの例

using McMaster.Extensions.CommandLineUtils;
using System;
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Text.RegularExpressions;

// カスタムバリデーション属性
public class FileExistsAttribute : ValidationAttribute
{
    public override bool IsValid(object value)
    {
        if (value is string filePath && !string.IsNullOrEmpty(filePath))
        {
            return File.Exists(filePath);
        }
        return false;
    }

    public override string FormatErrorMessage(string name)
    {
        return $"ファイル '{name}' が存在しません。";
    }
}

public class PortRangeAttribute : ValidationAttribute
{
    public override bool IsValid(object value)
    {
        if (value is int port)
        {
            return port >= 1024 && port <= 65535;
        }
        return false;
    }

    public override string FormatErrorMessage(string name)
    {
        return $"ポート番号は1024-65535の範囲で指定してください。";
    }
}

public class EmailAttribute : ValidationAttribute
{
    private static readonly Regex EmailRegex = new Regex(
        @"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
        RegexOptions.Compiled);

    public override bool IsValid(object value)
    {
        if (value is string email && !string.IsNullOrEmpty(email))
        {
            return EmailRegex.IsMatch(email);
        }
        return true; // 空の場合は有効とする
    }

    public override string FormatErrorMessage(string name)
    {
        return "有効なメールアドレスを入力してください。";
    }
}

[Command(Name = "validate-example", Description = "バリデーション例")]
public class ValidateExampleCommand
{
    [Option("-f|--file", Description = "入力ファイル")]
    [Required]
    [FileExists]
    public string InputFile { get; set; }

    [Option("-p|--port", Description = "ポート番号")]
    [Required]
    [PortRange]
    public int Port { get; set; }

    [Option("-e|--email", Description = "メールアドレス")]
    [Email]
    public string Email { get; set; }

    [Option("-n|--name", Description = "名前")]
    [Required]
    [StringLength(50, MinimumLength = 2)]
    public string Name { get; set; }

    [Option("-a|--age", Description = "年齢")]
    [Range(0, 150)]
    public int Age { get; set; }

    static int Main(string[] args)
        => CommandLineApplication.Execute<ValidateExampleCommand>(args);

    private int OnExecute()
    {
        Console.WriteLine("バリデーション成功!");
        Console.WriteLine($"ファイル: {InputFile}");
        Console.WriteLine($"ポート: {Port}");
        Console.WriteLine($"名前: {Name}");
        Console.WriteLine($"年齢: {Age}");

        if (!string.IsNullOrEmpty(Email))
        {
            Console.WriteLine($"メール: {Email}");
        }

        return 0;
    }
}

プロジェクトファイル例

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net6.0</TargetFramework>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="McMaster.Extensions.CommandLineUtils" Version="4.0.2" />
    <PackageReference Include="Microsoft.Extensions.Hosting" Version="6.0.0" />
    <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="6.0.0" />
  </ItemGroup>

</Project>