System.CommandLine

Microsoftが開発した.NET向けの公式コマンドライン解析ライブラリ。モダンで柔軟なAPIを提供します。

csharpdotnetclicommand-line

GitHub概要

dotnet/command-line-api

Command line parsing, invocation, and rendering of terminal output.

スター3,566
ウォッチ254
フォーク402
作成日:2018年4月17日
言語:C#
ライセンス:MIT License

トピックス

command-linecommandlineparsercompletionsdotnet-coredotnet-standarddotnet-suggesthacktoberfestparserparsingposixsystem-commandlineterminalvt100

スター履歴

dotnet/command-line-api Star History
データ取得日時: 2025/7/25 02:06

フレームワーク

System.CommandLine

概要

System.CommandLineは、.NET公式のコマンドラインアプリケーション開発ライブラリです。コマンドライン入力の解析や、ヘルプテキストの表示など、CLIアプリでよく必要とされる機能を提供します。.NET CLIや追加ツール、多くのグローバルツールやローカルツールで使用されており、Microsoft公式サポートの信頼性が高いライブラリです。トリミング対応やAOT(Ahead-of-Time)コンパイル対応により、高速で軽量なCLIアプリケーションの開発が可能です。

詳細

System.CommandLineライブラリは、アプリコードの記述に集中できるよう、コマンドライン入力の解析やヘルプページの生成といった定型処理を自動化します。入力解析コードから独立してアプリコードをテストできるため、テスタビリティも向上します。

現在の状況と将来展望

System.CommandLineプロジェクトは現在、現在のエコシステムとの整合性を図り、解析動作を分離して他のパーサーライブラリと共有できるようにし、長期メンテナンスを明確化するためのリセット作業が行われています。この作業により、System.CommandLineをプレビューモードから正式版に移行させることを目的としています。

主な特徴

  • 自動解析: POSIXまたはWindows規約に従ったコマンドライン入力の一貫した解析
  • 自動ヘルプ生成: コマンドやオプションから自動的にヘルプページを生成
  • タブ補完: 自動的なタブ補完機能をサポート
  • レスポンスファイル: 引数をファイルから読み込む機能
  • トリミング対応: 高速で軽量なAOT対応CLIアプリの開発が可能
  • 強力な型システム: string、string[]、int、bool、FileInfo、enum等の様々なオプション型をサポート
  • 複数レベルサブコマンド: ネストされたサブコマンド構造をサポート
  • エイリアス: コマンドやオプションのエイリアス機能
  • カスタム解析・検証: オプションの独自解析や検証ロジック
  • 依存性注入: .NET標準の依存性注入パターンをサポート

対応プラットフォーム

  • .NET 9: --prerelease オプションが必要(ベータ版のため)
  • クロスプラットフォーム: Windows、Linux、macOS で動作
  • AOT対応: Native AOTコンパイルでパフォーマンス最適化

エコシステムとの関係

.NET CLIをはじめとする多くの公式・非公式ツールで採用されており、.NETエコシステムの標準的なCLIライブラリとしての地位を確立しています。

メリット・デメリット

メリット

  • Microsoft公式サポート: .NETチームによる公式開発・保守
  • 自動化機能: 解析やヘルプ生成の自動化でコード記述に集中可能
  • テスタビリティ: 入力解析から独立したアプリロジックのテスト
  • 高パフォーマンス: トリミングやAOT対応による高速動作
  • 強力な型システム: C#の型システムを活用した安全なオプション処理
  • 豊富な機能: サブコマンド、エイリアス、補完機能等の充実
  • 標準準拠: POSIXやWindows規約への準拠
  • エコシステム統合: .NET CLIや他のツールとの一貫性

デメリット

  • プレビュー状態: まだベータ版のため本格運用には注意が必要
  • 学習コスト: 豊富な機能により初期学習に時間が必要
  • .NET依存: .NETプロジェクト以外では使用できない
  • API変更リスク: プレビュー版のため将来的なAPI変更の可能性
  • ドキュメント: 一部機能でコミュニティドキュメントが不足

主要リンク

書き方の例

基本的なコマンド作成

using System.CommandLine;

class Program
{
    static async Task<int> Main(string[] args)
    {
        // ルートコマンドの作成
        var rootCommand = new RootCommand("シンプルなCLIアプリケーション");

        // オプションの定義
        var nameOption = new Option<string>(
            name: "--name",
            description: "表示する名前")
        {
            IsRequired = true
        };

        var verboseOption = new Option<bool>(
            name: "--verbose",
            description: "詳細な出力を表示");

        // オプションをコマンドに追加
        rootCommand.AddOption(nameOption);
        rootCommand.AddOption(verboseOption);

        // コマンドハンドラーの設定
        rootCommand.SetHandler((string name, bool verbose) =>
        {
            if (verbose)
            {
                Console.WriteLine($"詳細モード: {name} さんへの挨拶を表示します");
            }
            Console.WriteLine($"こんにちは、{name} さん!");
        }, nameOption, verboseOption);

        // コマンドの実行
        return await rootCommand.InvokeAsync(args);
    }
}

サブコマンドの実装

using System.CommandLine;
using System.IO;

class Program
{
    static async Task<int> Main(string[] args)
    {
        // ルートコマンド
        var rootCommand = new RootCommand("ファイル管理ツール");

        // ファイル作成サブコマンド
        var createCommand = new Command("create", "新しいファイルを作成");
        var fileNameArgument = new Argument<string>("filename", "作成するファイル名");
        var contentOption = new Option<string?>("--content", "ファイルの内容");
        var forceOption = new Option<bool>("--force", "既存ファイルを上書き");

        createCommand.AddArgument(fileNameArgument);
        createCommand.AddOption(contentOption);
        createCommand.AddOption(forceOption);

        createCommand.SetHandler(async (string filename, string? content, bool force) =>
        {
            if (File.Exists(filename) && !force)
            {
                Console.WriteLine($"エラー: ファイル '{filename}' は既に存在します。--force オプションを使用してください。");
                return;
            }

            await File.WriteAllTextAsync(filename, content ?? "");
            Console.WriteLine($"ファイル '{filename}' を作成しました。");
        }, fileNameArgument, contentOption, forceOption);

        // ファイル一覧サブコマンド
        var listCommand = new Command("list", "ファイル一覧を表示");
        var pathArgument = new Argument<DirectoryInfo>("path", () => new DirectoryInfo("."), "対象ディレクトリ");
        var patternOption = new Option<string>("--pattern", () => "*", "ファイルパターン");
        var recursiveOption = new Option<bool>("--recursive", "サブディレクトリも対象にする");

        listCommand.AddArgument(pathArgument);
        listCommand.AddOption(patternOption);
        listCommand.AddOption(recursiveOption);

        listCommand.SetHandler((DirectoryInfo path, string pattern, bool recursive) =>
        {
            var searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
            var files = path.GetFiles(pattern, searchOption);

            Console.WriteLine($"ディレクトリ: {path.FullName}");
            Console.WriteLine($"パターン: {pattern}");
            Console.WriteLine($"再帰検索: {(recursive ? "有効" : "無効")}");
            Console.WriteLine();

            foreach (var file in files)
            {
                var relativePath = Path.GetRelativePath(path.FullName, file.FullName);
                Console.WriteLine($"  {relativePath} ({file.Length} bytes)");
            }

            Console.WriteLine($"\n{files.Length} 個のファイルが見つかりました。");
        }, pathArgument, patternOption, recursiveOption);

        // 削除サブコマンド
        var deleteCommand = new Command("delete", "ファイルを削除");
        var deleteFileArgument = new Argument<FileInfo>("file", "削除するファイル");
        var confirmOption = new Option<bool>("--confirm", "削除前に確認");

        deleteCommand.AddArgument(deleteFileArgument);
        deleteCommand.AddOption(confirmOption);

        deleteCommand.SetHandler((FileInfo file, bool confirm) =>
        {
            if (!file.Exists)
            {
                Console.WriteLine($"エラー: ファイル '{file.Name}' が見つかりません。");
                return;
            }

            if (confirm)
            {
                Console.Write($"ファイル '{file.Name}' を削除しますか? [y/N]: ");
                var response = Console.ReadLine();
                if (response?.ToLower() != "y" && response?.ToLower() != "yes")
                {
                    Console.WriteLine("削除をキャンセルしました。");
                    return;
                }
            }

            file.Delete();
            Console.WriteLine($"ファイル '{file.Name}' を削除しました。");
        }, deleteFileArgument, confirmOption);

        // サブコマンドをルートに追加
        rootCommand.AddCommand(createCommand);
        rootCommand.AddCommand(listCommand);
        rootCommand.AddCommand(deleteCommand);

        return await rootCommand.InvokeAsync(args);
    }
}

高度なオプション処理

using System.CommandLine;
using System.CommandLine.Parsing;

public enum LogLevel
{
    Debug,
    Info,
    Warning,
    Error
}

class Program
{
    static async Task<int> Main(string[] args)
    {
        var rootCommand = new RootCommand("高度なオプション処理の例");

        // 配列オプション
        var tagsOption = new Option<string[]>(
            name: "--tags",
            description: "タグのリスト")
        {
            AllowMultipleArgumentsPerToken = true
        };

        // Enum オプション
        var logLevelOption = new Option<LogLevel>(
            name: "--log-level",
            description: "ログレベル")
        {
            IsRequired = false
        };
        logLevelOption.SetDefaultValue(LogLevel.Info);

        // ファイル存在チェック付きオプション
        var inputFileOption = new Option<FileInfo?>(
            name: "--input",
            description: "入力ファイル");

        inputFileOption.AddValidator(result =>
        {
            var file = result.GetValueForOption(inputFileOption);
            if (file != null && !file.Exists)
            {
                result.ErrorMessage = $"ファイル '{file.FullName}' が存在しません。";
            }
        });

        // 数値範囲チェック付きオプション
        var portOption = new Option<int>(
            name: "--port",
            description: "ポート番号 (1024-65535)")
        {
            IsRequired = true
        };

        portOption.AddValidator(result =>
        {
            var port = result.GetValueForOption(portOption);
            if (port < 1024 || port > 65535)
            {
                result.ErrorMessage = "ポート番号は 1024 から 65535 の範囲で指定してください。";
            }
        });

        // カスタム解析オプション
        var configOption = new Option<Dictionary<string, string>>(
            name: "--config",
            description: "設定値 (key=value 形式)",
            parseArgument: result =>
            {
                var config = new Dictionary<string, string>();
                foreach (var token in result.Tokens)
                {
                    var parts = token.Value.Split('=', 2);
                    if (parts.Length != 2)
                    {
                        result.ErrorMessage = $"無効な設定形式: {token.Value}. key=value 形式で指定してください。";
                        return config;
                    }
                    config[parts[0]] = parts[1];
                }
                return config;
            });

        rootCommand.AddOption(tagsOption);
        rootCommand.AddOption(logLevelOption);
        rootCommand.AddOption(inputFileOption);
        rootCommand.AddOption(portOption);
        rootCommand.AddOption(configOption);

        rootCommand.SetHandler((string[] tags, LogLevel logLevel, FileInfo? inputFile, int port, Dictionary<string, string> config) =>
        {
            Console.WriteLine("=== 設定内容 ===");
            Console.WriteLine($"タグ: [{string.Join(", ", tags)}]");
            Console.WriteLine($"ログレベル: {logLevel}");
            Console.WriteLine($"入力ファイル: {inputFile?.FullName ?? "なし"}");
            Console.WriteLine($"ポート: {port}");
            
            Console.WriteLine("設定:");
            foreach (var kvp in config)
            {
                Console.WriteLine($"  {kvp.Key} = {kvp.Value}");
            }
        }, tagsOption, logLevelOption, inputFileOption, portOption, configOption);

        return await rootCommand.InvokeAsync(args);
    }
}

依存性注入を使った実装

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.CommandLine;
using System.CommandLine.Hosting;

// サービスインターface
public interface IDataService
{
    Task<string[]> GetDataAsync();
    Task SaveDataAsync(string data);
}

// サービス実装
public class FileDataService : IDataService
{
    private readonly ILogger<FileDataService> _logger;
    private readonly string _dataFile = "data.txt";

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

    public async Task<string[]> GetDataAsync()
    {
        _logger.LogInformation("データファイルから読み込み中...");
        if (!File.Exists(_dataFile))
        {
            return Array.Empty<string>();
        }
        return await File.ReadAllLinesAsync(_dataFile);
    }

    public async Task SaveDataAsync(string data)
    {
        _logger.LogInformation("データファイルに保存中...");
        await File.AppendAllTextAsync(_dataFile, data + Environment.NewLine);
    }
}

// コマンドハンドラー
public class DataCommands
{
    private readonly IDataService _dataService;
    private readonly ILogger<DataCommands> _logger;

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

    public async Task ListData()
    {
        _logger.LogInformation("データ一覧を表示");
        var data = await _dataService.GetDataAsync();
        
        if (data.Length == 0)
        {
            Console.WriteLine("データがありません。");
            return;
        }

        Console.WriteLine("保存されたデータ:");
        for (int i = 0; i < data.Length; i++)
        {
            Console.WriteLine($"  {i + 1}. {data[i]}");
        }
    }

    public async Task AddData(string value)
    {
        _logger.LogInformation("データを追加: {Value}", value);
        await _dataService.SaveDataAsync(value);
        Console.WriteLine($"データ '{value}' を追加しました。");
    }
}

class Program
{
    static async Task<int> Main(string[] args)
    {
        var rootCommand = new RootCommand("依存性注入を使用したCLIアプリ");

        // listコマンド
        var listCommand = new Command("list", "データ一覧を表示");
        listCommand.SetHandler<DataCommands>(async (commands) => await commands.ListData());

        // addコマンド
        var addCommand = new Command("add", "データを追加");
        var valueArgument = new Argument<string>("value", "追加する値");
        addCommand.AddArgument(valueArgument);
        addCommand.SetHandler<string, DataCommands>(async (value, commands) => await commands.AddData(value), valueArgument);

        rootCommand.AddCommand(listCommand);
        rootCommand.AddCommand(addCommand);

        // ホスト設定
        var builder = Host.CreateDefaultBuilder(args)
            .ConfigureServices(services =>
            {
                services.AddSingleton<IDataService, FileDataService>();
                services.AddSingleton<DataCommands>();
            })
            .UseCommandHandler<RootCommand, DataCommands>();

        var host = builder.Build();
        return await host.RunCommandLineApplicationAsync(rootCommand, args);
    }
}

エラーハンドリングとカスタムミドルウェア

using System.CommandLine;
using System.CommandLine.Builder;
using System.CommandLine.Parsing;

class Program
{
    static async Task<int> Main(string[] args)
    {
        var rootCommand = new RootCommand("エラーハンドリングの例");

        var riskyCommand = new Command("risky", "エラーが発生する可能性のあるコマンド");
        var shouldFailOption = new Option<bool>("--fail", "意図的にエラーを発生させる");
        riskyCommand.AddOption(shouldFailOption);

        riskyCommand.SetHandler(async (bool shouldFail) =>
        {
            Console.WriteLine("危険な処理を開始...");
            
            if (shouldFail)
            {
                throw new InvalidOperationException("意図的なエラーが発生しました。");
            }

            // 時間のかかる処理をシミュレート
            await Task.Delay(1000);
            Console.WriteLine("処理が正常に完了しました。");
        }, shouldFailOption);

        rootCommand.AddCommand(riskyCommand);

        // カスタムコマンドラインビルダー
        var commandLineBuilder = new CommandLineBuilder(rootCommand)
            .UseDefaults()
            .UseExceptionHandler((exception, context) =>
            {
                Console.ForegroundColor = ConsoleColor.Red;
                Console.WriteLine($"エラーが発生しました: {exception.Message}");
                Console.ResetColor();
                
                if (exception is InvalidOperationException)
                {
                    Console.WriteLine("これは予期されたエラーです。--fail オプションを外して再実行してください。");
                    context.ExitCode = 2;
                }
                else
                {
                    Console.WriteLine("予期しないエラーが発生しました。");
                    context.ExitCode = 1;
                }
            })
            .AddMiddleware(async (context, next) =>
            {
                // 実行前のログ
                Console.WriteLine($"コマンド実行開始: {string.Join(" ", context.ParseResult.Tokens.Select(t => t.Value))}");
                var startTime = DateTime.Now;

                try
                {
                    await next(context);
                }
                finally
                {
                    // 実行後のログ
                    var duration = DateTime.Now - startTime;
                    Console.WriteLine($"コマンド実行終了: {duration.TotalMilliseconds:F2}ms");
                }
            });

        var parser = commandLineBuilder.Build();
        return await parser.InvokeAsync(args);
    }
}

レスポンスファイルとカスタム補完

using System.CommandLine;
using System.CommandLine.Completions;

class Program
{
    static async Task<int> Main(string[] args)
    {
        var rootCommand = new RootCommand("レスポンスファイルと補完の例");

        // ユーザー名オプション(カスタム補完付き)
        var userOption = new Option<string>("--user", "ユーザー名");
        userOption.AddCompletions(context =>
        {
            // 実際のアプリケーションでは、データベースやAPIから取得
            var availableUsers = new[] { "alice", "bob", "charlie", "david" };
            var currentInput = context.WordToComplete;
            
            return availableUsers
                .Where(user => user.StartsWith(currentInput, StringComparison.OrdinalIgnoreCase))
                .Select(user => new CompletionItem(user))
                .ToArray();
        });

        // 環境オプション(固定の選択肢)
        var envOption = new Option<string>("--environment", "環境名");
        envOption.AddCompletions("development", "staging", "production");

        // ファイルパスオプション(ファイルシステム補完)
        var configOption = new Option<FileInfo>("--config", "設定ファイル");
        // FileInfo型は自動的にファイルパス補完が有効になる

        rootCommand.AddOption(userOption);
        rootCommand.AddOption(envOption);
        rootCommand.AddOption(configOption);

        rootCommand.SetHandler((string? user, string? environment, FileInfo? config) =>
        {
            Console.WriteLine("=== 実行パラメータ ===");
            Console.WriteLine($"ユーザー: {user ?? "未指定"}");
            Console.WriteLine($"環境: {environment ?? "未指定"}");
            Console.WriteLine($"設定ファイル: {config?.FullName ?? "未指定"}");

            // レスポンスファイルの使用例を表示
            Console.WriteLine();
            Console.WriteLine("=== レスポンスファイルの使用例 ===");
            Console.WriteLine("引数をファイルに保存して使用できます:");
            Console.WriteLine("  echo '--user alice --environment production' > args.txt");
            Console.WriteLine("  myapp @args.txt");
        }, userOption, envOption, configOption);

        return await rootCommand.InvokeAsync(args);
    }
}

プロジェクト設定とNuGetパッケージ

<!-- Project.csproj -->
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <PublishAot>true</PublishAot>
    <InvariantGlobalization>true</InvariantGlobalization>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
    <PackageReference Include="System.CommandLine.Hosting" Version="0.4.0-alpha.22272.1" />
    <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
    <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" />
  </ItemGroup>

</Project>
# インストールコマンド
dotnet add package System.CommandLine --prerelease

# 実行例
dotnet run -- --help
dotnet run -- add "サンプルデータ" --verbose
dotnet run -- list --log-level Debug

# レスポンスファイルの例
echo "--user alice --environment production --config config.json" > response.txt
dotnet run -- @response.txt

# AOTパブリッシュ
dotnet publish -c Release -r win-x64 --self-contained