Skip to content

cli

napt.cli

Command-line interface for NAPT.

This module provides the main CLI entry point for the napt tool, offering commands for recipe validation, package building, and deployment management.

Commands:

init: Initialize a new NAPT project
validate: Validate recipe syntax and configuration
discover: Discover latest version and download installer
build: Build PSADT package from recipe
package: Create .intunewin package for Intune (recipe-based)
upload: Upload .intunewin package to Microsoft Intune
promote: Plan and apply deployment ring promotion
status: Show deployment state across all apps
Example

Validate recipe syntax:

$ napt validate recipes/Google/chrome.yaml

Discover latest version:

$ napt discover recipes/Google/chrome.yaml

Build PSADT package:

$ napt build recipes/Google/chrome.yaml

Create .intunewin package:

$ napt package recipes/Google/chrome.yaml

Upload to Intune:

$ napt upload recipes/Google/chrome.yaml

Enable verbose output:

$ napt discover recipes/Google/chrome.yaml --verbose

Enable debug output:

$ napt discover recipes/Google/chrome.yaml --debug

Exit Codes:

  • 0: Success
  • 1: Error (configuration, download, or validation failure)
Note

The CLI uses argparse for command parsing (stdlib, zero dependencies). Commands are registered with subparsers for clean organization. Each command has its own handler function (cmd_<command>). Verbose mode shows full tracebacks on errors for debugging. Debug mode implies verbose mode and shows detailed configuration dumps.

cmd_validate

cmd_validate(args: Namespace) -> int

Handler for 'napt validate' command.

Validates recipe syntax and configuration without downloading files or making network calls. This is useful for quick feedback during recipe development and for CI/CD pre-checks.

Parameters:

Name Type Description Default
args Namespace

Parsed command-line arguments containing recipe path and verbose flag.

required

Returns:

Type Description
int

Exit code (0 for valid recipe, 1 for invalid).

Note

Prints validation results, errors, and warnings to stdout.

Source code in napt/cli.py
def cmd_validate(args: argparse.Namespace) -> int:
    """Handler for 'napt validate' command.

    Validates recipe syntax and configuration without downloading files or
    making network calls. This is useful for quick feedback during recipe
    development and for CI/CD pre-checks.

    Args:
        args: Parsed command-line arguments containing
            recipe path and verbose flag.

    Returns:
        Exit code (0 for valid recipe, 1 for invalid).

    Note:
        Prints validation results, errors, and warnings to stdout.

    """
    # Configure global logger
    logger = get_logger(verbose=args.verbose, debug=args.debug)
    set_global_logger(logger)

    recipe_path = Path(args.recipe).resolve()

    print(f"Validating recipe: {recipe_path}")
    print()

    # Validate the recipe
    result = validate_recipe(recipe_path)

    # Display results
    print("=" * 70)
    print("VALIDATION RESULTS")
    print("=" * 70)
    print(f"Recipe:      {result.recipe_path}")
    print(f"Status:      {result.status.upper()}")
    print(f"App Count:   {result.app_count}")
    print()

    # Show warnings if any
    if result.warnings:
        print(f"Warnings ({len(result.warnings)}):")
        for warning in result.warnings:
            print(f"  [WARNING] {warning}")
        print()

    # Show errors if any
    if result.errors:
        print(f"Errors ({len(result.errors)}):")
        for error in result.errors:
            print(f"  [X] {error}")
        print()

    print("=" * 70)

    # Show provenance in debug mode (useful for both valid and invalid recipes)
    if args.debug:
        try:
            config = load_effective_config(recipe_path)
            provenance = config.get("_provenance")
            if provenance:
                print()
                print("CONFIGURATION PROVENANCE")
                print("-" * 70)
                _print_provenance(config, provenance)
                print("-" * 70)
        except Exception:
            pass  # Best-effort; config may fail to load for invalid recipes

    if result.status == "valid":
        print()
        print("[SUCCESS] Recipe is valid!")
        return 0
    else:
        print()
        print(f"[FAILED] Recipe validation failed with {len(result.errors)} error(s).")
        return 1

cmd_discover

cmd_discover(args: Namespace) -> int

Handler for 'napt discover' command.

Discovers the latest version of an application by querying the source and downloading the installer. This command validates the recipe YAML, uses the configured discovery strategy to find the latest version, downloads the installer (or uses cached version via ETag), extracts version information, updates the discovery cache, and records the release as a pending publication candidate in deployment state when it differs from the published version.

Parameters:

Name Type Description Default
args Namespace

Parsed command-line arguments containing recipe path, output directory, cache file path, deployment state directory, and flags.

required

Returns:

Type Description
int

Exit code (0 for success, 1 for failure).

Note

Downloads installer file to output_dir (or uses cached version). Updates the discovery cache with version and ETag information and the app's deployment state file with the pending release. Prints progress and results to stdout. Prints errors with optional traceback if verbose/debug.

Source code in napt/cli.py
def cmd_discover(args: argparse.Namespace) -> int:
    """Handler for 'napt discover' command.

    Discovers the latest version of an application by querying the source
    and downloading the installer. This command validates the recipe YAML,
    uses the configured discovery strategy to find the latest version,
    downloads the installer (or uses cached version via ETag), extracts
    version information, updates the discovery cache, and records the
    release as a pending publication candidate in deployment state when it
    differs from the published version.

    Args:
        args: Parsed command-line arguments containing
            recipe path, output directory, cache file path, deployment
            state directory, and flags.

    Returns:
        Exit code (0 for success, 1 for failure).

    Note:
        Downloads installer file to output_dir (or uses cached version).
        Updates the discovery cache with version and ETag information and
        the app's deployment state file with the pending release. Prints
        progress and results to stdout. Prints errors with optional
        traceback if verbose/debug.

    """
    # Configure global logger
    logger = get_logger(verbose=args.verbose, debug=args.debug)
    set_global_logger(logger)

    recipe_path = Path(args.recipe).resolve()
    output_dir = Path(args.output_dir).resolve() if args.output_dir else None

    if not recipe_path.exists():
        print(f"Error: Recipe file not found: {recipe_path}")
        return 1

    print(f"Discovering version for recipe: {recipe_path}")
    if output_dir:
        print(f"Output directory: {output_dir}")
    print()

    try:
        result = discover_recipe(
            recipe_path,
            output_dir,
            cache_file=args.cache_file,
            state_dir=args.state_dir,
            stateless=args.stateless,
        )
    except (ConfigError, NetworkError, PackagingError) as err:
        print(f"Error: {err}")
        if args.verbose or args.debug:
            import traceback

            traceback.print_exc()
        return 1
    except NAPTError as err:
        # Catch any other NAPT errors we might have missed
        print(f"Error: {err}")
        if args.verbose or args.debug:
            import traceback

            traceback.print_exc()
        return 1

    # Display results
    print("=" * 70)
    print("DISCOVERY RESULTS")
    print("=" * 70)
    print(f"App Name:        {result.app_name}")
    print(f"App ID:          {result.app_id}")
    print(f"Strategy:        {result.strategy}")
    print(f"Version:         {result.version}")
    print(f"Version Source:  {result.version_source}")
    print(f"File Path:       {result.file_path}")
    print(f"SHA-256:         {result.sha256}")
    print(f"Status:          {result.status}")
    print("=" * 70)
    print()
    print("[SUCCESS] Version discovered successfully!")

    return 0

cmd_build

cmd_build(args: Namespace) -> int

Handler for 'napt build' command.

Builds a PSADT package from a recipe and downloaded installer. This command loads the recipe configuration, finds the downloaded installer, extracts version from the installer file (filesystem is truth), downloads/caches the specified PSADT release, creates build directory structure, copies PSADT files pristine from cache, generates Invoke-AppDeployToolkit.ps1 with recipe values, copies installer to Files/ directory, and applies custom branding.

Parameters:

Name Type Description Default
args Namespace

Parsed command-line arguments containing recipe path, downloads directory, output directory, and flags.

required

Returns:

Type Description
int

Exit code (0 for success, 1 for failure).

Note

Creates build directory structure. Downloads PSADT release if not cached. Generates Invoke-AppDeployToolkit.ps1. Copies files to build directory. Prints progress and results to stdout.

Source code in napt/cli.py
def cmd_build(args: argparse.Namespace) -> int:
    """Handler for 'napt build' command.

    Builds a PSADT package from a recipe and downloaded installer. This command
    loads the recipe configuration, finds the downloaded installer, extracts
    version from the installer file (filesystem is truth), downloads/caches
    the specified PSADT release, creates build directory structure, copies
    PSADT files pristine from cache, generates Invoke-AppDeployToolkit.ps1
    with recipe values, copies installer to Files/ directory, and applies
    custom branding.

    Args:
        args: Parsed command-line arguments containing
            recipe path, downloads directory, output directory, and flags.

    Returns:
        Exit code (0 for success, 1 for failure).

    Note:
        Creates build directory structure. Downloads PSADT release if not cached.
        Generates Invoke-AppDeployToolkit.ps1. Copies files to build directory.
        Prints progress and results to stdout.

    """
    # Configure global logger
    logger = get_logger(verbose=args.verbose, debug=args.debug)
    set_global_logger(logger)

    recipe_path = Path(args.recipe).resolve()
    downloads_dir = Path(args.downloads_dir).resolve() if args.downloads_dir else None
    output_dir = Path(args.output_dir) if args.output_dir else None

    if not recipe_path.exists():
        print(f"Error: Recipe file not found: {recipe_path}")
        return 1

    print(f"Building PSADT package for recipe: {recipe_path}")
    if downloads_dir:
        print(f"Downloads directory: {downloads_dir}")
    if output_dir:
        print(f"Output directory: {output_dir}")
    print()

    try:
        result = build_package(
            recipe_path,
            downloads_dir=downloads_dir,
            output_dir=output_dir,
        )
    except (ConfigError, NetworkError, PackagingError) as err:
        print(f"Error: {err}")
        if args.verbose or args.debug:
            import traceback

            traceback.print_exc()
        return 1
    except NAPTError as err:
        # Catch any other NAPT errors we might have missed
        print(f"Error: {err}")
        if args.verbose or args.debug:
            import traceback

            traceback.print_exc()
        return 1

    # Display results
    print("=" * 70)
    print("BUILD RESULTS")
    print("=" * 70)
    print(f"App Name:        {result.app_name}")
    print(f"App ID:          {result.app_id}")
    print(f"Version:         {result.version}")
    print(f"PSADT Version:   {result.psadt_version}")
    print(f"Build Directory: {result.build_dir}")
    print(f"Status:          {result.status}")
    print("=" * 70)
    print()
    print("[SUCCESS] PSADT package built successfully!")

    return 0

cmd_package

cmd_package(args: Namespace) -> int

Handler for 'napt package' command.

Creates a .intunewin package from a PSADT build for the given recipe. Infers the build directory from the recipe's app ID, removes any previously packaged version (single-slot), copies detection scripts alongside the .intunewin file so 'napt upload' is self-contained, and optionally cleans the source build directory after packaging.

Parameters:

Name Type Description Default
args Namespace

Parsed command-line arguments containing recipe path, version, output directory, clean flag, and debug flags.

required

Returns:

Type Description
int

Exit code (0 for success, 1 for failure).

Note

Without --version, picks the most recently modified build. Run 'napt build' before 'napt package'. Downloads IntuneWinAppUtil.exe if not cached. Optionally removes the build directory if --clean-source.

Source code in napt/cli.py
def cmd_package(args: argparse.Namespace) -> int:
    """Handler for 'napt package' command.

    Creates a .intunewin package from a PSADT build for the given recipe.
    Infers the build directory from the recipe's app ID, removes any
    previously packaged version (single-slot), copies detection scripts
    alongside the .intunewin file so 'napt upload' is self-contained, and
    optionally cleans the source build directory after packaging.

    Args:
        args: Parsed command-line arguments containing recipe path, version,
            output directory, clean flag, and debug flags.

    Returns:
        Exit code (0 for success, 1 for failure).

    Note:
        Without --version, picks the most recently modified build. Run
        'napt build' before 'napt package'. Downloads IntuneWinAppUtil.exe
        if not cached. Optionally removes the build directory if --clean-source.

    """
    # Configure global logger
    logger = get_logger(verbose=args.verbose, debug=args.debug)
    set_global_logger(logger)

    recipe_path = Path(args.recipe).resolve()
    builds_dir = Path(args.builds_dir).resolve() if args.builds_dir else None

    if not recipe_path.exists():
        print(f"Error: Recipe file not found: {recipe_path}")
        return 1

    try:
        build_dir = _resolve_build_dir_from_recipe(
            recipe_path, version=args.version, builds_dir=builds_dir
        )
    except ConfigError as err:
        print(f"Error: {err}")
        return 1

    config = load_effective_config(recipe_path)

    output_dir = (
        Path(args.output_dir)
        if args.output_dir
        else Path(config["directories"]["package"])
    )
    tool_release = config["intunewin"]["release"]

    print(f"Creating .intunewin package from: {build_dir}")
    print(f"Output directory: {output_dir}")
    print()

    try:
        result = create_intunewin(
            build_dir,
            output_dir=output_dir,
            clean_source=args.clean_source,
            tool_release=tool_release,
        )
    except (ConfigError, NetworkError, PackagingError) as err:
        print(f"Error: {err}")
        if args.verbose or args.debug:
            import traceback

            traceback.print_exc()
        return 1
    except NAPTError as err:
        # Catch any other NAPT errors we might have missed
        print(f"Error: {err}")
        if args.verbose or args.debug:
            import traceback

            traceback.print_exc()
        return 1

    # Display results
    print("=" * 70)
    print("PACKAGE RESULTS")
    print("=" * 70)
    print(f"App ID:          {result.app_id}")
    print(f"Version:         {result.version}")
    print(f"Package Path:    {result.package_path}")
    if args.clean_source:
        print(f"Build Directory: {result.build_dir} (removed)")
    else:
        print(f"Build Directory: {result.build_dir}")
    print(f"Status:          {result.status}")
    print("=" * 70)
    print()
    print("[SUCCESS] .intunewin package created successfully!")

    return 0

cmd_upload

cmd_upload(args: Namespace) -> int

Handler for 'napt upload' command.

Uploads the .intunewin package for a recipe to Microsoft Intune via the Graph API. Infers the package path from the recipe's app ID. Authentication is automatic: tries EnvironmentCredential (AZURE_CLIENT_ID + AZURE_CLIENT_SECRET + AZURE_TENANT_ID), ManagedIdentityCredential, and DeviceCodeCredential (browser login) in that order.

Parameters:

Name Type Description Default
args Namespace

Parsed command-line arguments containing recipe path and debug flags.

required

Returns:

Type Description
int

Exit code (0 for success, 1 for failure).

Note

Run 'napt package' before this command to create the .intunewin file. Re-running an upload adopts existing NAPT-stamped apps instead of creating duplicates; --force re-sends metadata and content to them. Developers: set AZURE_CLIENT_ID and AZURE_TENANT_ID, then complete the device code flow when prompted. Set AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID for CI/CD.

Source code in napt/cli.py
def cmd_upload(args: argparse.Namespace) -> int:
    """Handler for 'napt upload' command.

    Uploads the .intunewin package for a recipe to Microsoft Intune via the
    Graph API. Infers the package path from the recipe's app ID. Authentication
    is automatic: tries EnvironmentCredential (AZURE_CLIENT_ID +
    AZURE_CLIENT_SECRET + AZURE_TENANT_ID), ManagedIdentityCredential, and
    DeviceCodeCredential (browser login) in that order.

    Args:
        args: Parsed command-line arguments containing recipe path and
            debug flags.

    Returns:
        Exit code (0 for success, 1 for failure).

    Note:
        Run 'napt package' before this command to create the .intunewin file.
        Re-running an upload adopts existing NAPT-stamped apps instead of
        creating duplicates; --force re-sends metadata and content to them.
        Developers: set AZURE_CLIENT_ID and AZURE_TENANT_ID, then complete
        the device code flow when prompted.
        Set AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID for CI/CD.

    """
    # Configure global logger
    logger = get_logger(verbose=args.verbose, debug=args.debug)
    set_global_logger(logger)

    recipe_path = Path(args.recipe).resolve()

    if not recipe_path.exists():
        print(f"Error: Recipe file not found: {recipe_path}")
        return 1

    print(f"Uploading package for recipe: {recipe_path}")
    print()

    try:
        result = upload_package(recipe_path, force=args.force)
    except ConfigError as err:
        print(f"Error: {err}")
        if args.verbose or args.debug:
            import traceback

            traceback.print_exc()
        return 1
    except AuthError as err:
        print(f"Authentication error: {err}")
        if args.verbose or args.debug:
            import traceback

            traceback.print_exc()
        return 1
    except (NetworkError, PackagingError) as err:
        print(f"Error: {err}")
        if args.verbose or args.debug:
            import traceback

            traceback.print_exc()
        return 1
    except NAPTError as err:
        print(f"Error: {err}")
        if args.verbose or args.debug:
            import traceback

            traceback.print_exc()
        return 1

    # Display results
    print("=" * 70)
    print("UPLOAD RESULTS")
    print("=" * 70)
    print(f"App ID:          {result.app_id}")
    print(f"App Name:        {result.app_name}")
    print(f"Version:         {result.version}")
    if result.intune_app_id:
        print(f"Intune Win32 App ID:    {result.intune_app_id}")
    if result.intune_update_app_id:
        print(f"Intune Win32 Update ID: {result.intune_update_app_id}")
    print(f"Package:         {result.package_path}")
    print(f"Status:          {result.status}")
    print("=" * 70)
    print()
    print("[SUCCESS] Package uploaded to Intune successfully!")

    return 0

cmd_promote_plan

cmd_promote_plan(args: Namespace) -> int

Handler for 'napt promote plan' command.

Computes promotion actions for all recipes (or one recipe) as a pure function of deployment state, configuration, and the clock, and writes one plan file per app with work. Read-only with respect to Intune, and — unless --reconcile recovers a lost publication writeback first — to deployment state; an app's stale plan file is removed when none of its actions remain eligible.

Parameters:

Name Type Description Default
args Namespace

Parsed command-line arguments containing the recipes path, state directory, and flags.

required

Returns:

Type Description
int

Exit code (0 for success — with or without planned actions,

int

1 for failure).

Source code in napt/cli.py
def cmd_promote_plan(args: argparse.Namespace) -> int:
    """Handler for 'napt promote plan' command.

    Computes promotion actions for all recipes (or one recipe) as a pure
    function of deployment state, configuration, and the clock, and
    writes one plan file per app with work. Read-only with respect to
    Intune, and — unless --reconcile recovers a lost publication
    writeback first — to deployment state; an app's stale plan file is
    removed when none of its actions remain eligible.

    Args:
        args: Parsed command-line arguments containing the recipes path,
            state directory, and flags.

    Returns:
        Exit code (0 for success — with or without planned actions,
        1 for failure).

    """
    logger = get_logger(verbose=args.verbose, debug=args.debug)
    set_global_logger(logger)

    recipes = Path(args.recipes)

    print(f"Planning promotions for: {recipes}")
    print()

    try:
        state_dir = (
            Path(args.state_dir)
            if args.state_dir is not None
            else resolve_state_dir(recipes)
        )
        configs = load_recipe_configs(recipes)
        recovered: list[dict[str, Any]] = []
        drift: list[dict[str, Any]] = []
        if args.reconcile or args.check_drift:
            # One authenticated session serves reconciliation, plan
            # validation, and the drift check. Reconciliation runs
            # before planning so recovered releases are promotable this
            # run; drift runs after it so repaired state is compared.
            access_token = get_access_token()
            existing_apps = list_mobile_apps(access_token)
            group_id_cache: dict[str, str] = {}
            if args.reconcile:
                recovered = reconcile_publications(
                    access_token, configs, state_dir / "deployment", existing_apps
                )
            actions = plan_promotions(recipes, state_dir=state_dir / "deployment")
            # A plan with an unresolvable group must never become a
            # reviewable promotion PR: fail hard instead of writing it.
            problems = unresolvable_groups(access_token, actions, group_id_cache)
            if problems:
                raise ConfigError(
                    "Plan validation failed; no plan was written. "
                    "Unresolvable groups:\n  "
                    + "\n  ".join(problems)
                    + "\nFix the group configuration and re-run."
                )
            if args.check_drift:
                drift = detect_drift(
                    access_token,
                    configs,
                    state_dir / "deployment",
                    existing_apps,
                    group_id_cache=group_id_cache,
                )
        else:
            actions = plan_promotions(recipes, state_dir=state_dir / "deployment")
            if actions:
                logger.warning(
                    "PROMOTE",
                    "Plan groups not validated against Entra ID (offline "
                    "run); apply validates them before assigning.",
                )
        written = write_plan_files(actions, state_dir, configs)
    except AuthError as err:
        print(f"Authentication error: {err}")
        if args.verbose or args.debug:
            import traceback

            traceback.print_exc()
        return 1
    except (ConfigError, StateError) as err:
        print(f"Error: {err}")
        if args.verbose or args.debug:
            import traceback

            traceback.print_exc()
        return 1
    except NAPTError as err:
        print(f"Error: {err}")
        if args.verbose or args.debug:
            import traceback

            traceback.print_exc()
        return 1

    print("=" * 70)
    print("PROMOTION PLAN")
    print("=" * 70)
    if actions:
        for action in actions:
            print(f"  {_describe_action(action)}")
        print("=" * 70)
        print()
        print(
            f"[OK] Plan written: {len(written)} file(s) in "
            f"{plans_dir_for(state_dir)} ({len(actions)} action(s))"
        )
    else:
        print("  No promotions eligible.")
        print("=" * 70)
        print()
        print("[OK] Nothing to promote. No plan files needed.")

    if args.reconcile:
        _print_recovered(recovered)
    if args.check_drift:
        _print_drift(drift)

    return 0

cmd_promote_apply

cmd_promote_apply(args: Namespace) -> int

Handler for 'napt promote apply' command.

Executes promotion plans against Intune: assigns install entries, promotes releases through rings, displaces the older releases they replace, and retires them per the retention policy. Consumes each per-app plan file after its app applies fully; otherwise plans fresh and applies immediately. One app's failure keeps its plan file for retry and never blocks the others, and stale or already-applied actions are skipped with a warning, so re-running after a partial failure is safe.

Parameters:

Name Type Description Default
args Namespace

Parsed command-line arguments containing the recipes path, state directory, plan file, and flags.

required

Returns:

Type Description
int

Exit code (0 for success — including nothing to apply,

int

1 for failure, including any app whose plan failed to apply).

Source code in napt/cli.py
def cmd_promote_apply(args: argparse.Namespace) -> int:
    """Handler for 'napt promote apply' command.

    Executes promotion plans against Intune: assigns install entries,
    promotes releases through rings, displaces the older releases they
    replace, and retires them per the retention policy. Consumes each
    per-app plan file after its app applies fully; otherwise plans
    fresh and applies immediately. One app's failure keeps its plan
    file for retry and never blocks the others, and stale or
    already-applied actions are skipped with a warning, so re-running
    after a partial failure is safe.

    Args:
        args: Parsed command-line arguments containing the recipes path,
            state directory, plan file, and flags.

    Returns:
        Exit code (0 for success — including nothing to apply,
        1 for failure, including any app whose plan failed to apply).

    """
    logger = get_logger(verbose=args.verbose, debug=args.debug)
    set_global_logger(logger)

    recipes = Path(args.recipes)

    print(f"Applying promotions for: {recipes}")
    print()

    try:
        state_dir = (
            Path(args.state_dir)
            if args.state_dir is not None
            else resolve_state_dir(recipes)
        )
        summary = apply_plan(
            recipes,
            state_dir=state_dir,
            plan_file=args.plan_file,
        )
    except AuthError as err:
        print(f"Authentication error: {err}")
        if args.verbose or args.debug:
            import traceback

            traceback.print_exc()
        return 1
    except (ConfigError, NetworkError, StateError) as err:
        print(f"Error: {err}")
        if args.verbose or args.debug:
            import traceback

            traceback.print_exc()
        return 1
    except NAPTError as err:
        print(f"Error: {err}")
        if args.verbose or args.debug:
            import traceback

            traceback.print_exc()
        return 1

    applied = summary["applied"]
    skipped = summary["skipped"]
    failed = summary["failed"]

    print("=" * 70)
    print("PROMOTION APPLY")
    print("=" * 70)
    if not applied and not skipped and not failed:
        print("  Nothing to apply.")
    for action in applied:
        print(f"  [OK] {_describe_action(action)}")
    for entry in skipped:
        print(f"  [SKIP] {_describe_action(entry['action'])} ({entry['reason']})")
    for entry in failed:
        print(f"  [FAIL] {entry['app_id']}: {entry['error']}")
    print("=" * 70)

    if summary.get("recovered"):
        _print_recovered(summary["recovered"])
    if summary.get("drift"):
        _print_drift(summary["drift"])

    print()
    if failed:
        print(
            f"[FAIL] Applied {len(applied)} action(s), skipped "
            f"{len(skipped)}; {len(failed)} app(s) failed and kept "
            "their plan files. Fix the errors and re-run."
        )
        return 1
    print(f"[SUCCESS] Applied {len(applied)} action(s), " f"skipped {len(skipped)}.")

    return 0

cmd_status

cmd_status(args: Namespace) -> int

Handler for 'napt status' command.

Aggregates all per-app deployment state files into one view: the published version, pending release, and which version holds each ring.

Parameters:

Name Type Description Default
args Namespace

Parsed command-line arguments containing the state directory, output format, and flags.

required

Returns:

Type Description
int

Exit code (0 for success, 1 for failure).

Source code in napt/cli.py
def cmd_status(args: argparse.Namespace) -> int:
    """Handler for 'napt status' command.

    Aggregates all per-app deployment state files into one view: the
    published version, pending release, and which version holds each ring.

    Args:
        args: Parsed command-line arguments containing the state
            directory, output format, and flags.

    Returns:
        Exit code (0 for success, 1 for failure).

    """
    logger = get_logger(verbose=args.verbose, debug=args.debug)
    set_global_logger(logger)

    deployment_dir = Path(args.state_dir) / "deployment"

    try:
        rows = summarize_deployment_states(deployment_dir)
    except (ConfigError, StateError) as err:
        print(f"Error: {err}")
        if args.verbose or args.debug:
            import traceback

            traceback.print_exc()
        return 1

    if args.format == "json":
        import json

        print(json.dumps(rows, indent=2, sort_keys=True))
        return 0

    if not rows:
        print(f"No deployment state found in {deployment_dir}")
        return 0

    headers = ("App", "Published", "Pending", "Rings")
    table = [
        (
            row["app_id"],
            row["published"] or "-",
            row["pending"] or "-",
            ", ".join(f"{name}={ver}" for name, ver in row["rings"].items()) or "-",
        )
        for row in rows
    ]
    widths = [
        max(len(headers[col]), *(len(line[col]) for line in table))
        for col in range(len(headers))
    ]
    print("  ".join(h.ljust(widths[i]) for i, h in enumerate(headers)))
    print("  ".join("-" * w for w in widths))
    for line in table:
        print("  ".join(cell.ljust(widths[i]) for i, cell in enumerate(line)))

    return 0

cmd_init

cmd_init(args: Namespace) -> int

Handler for 'napt init' command.

Initializes a new NAPT project by creating the directory structure and default configuration files. This command creates the recipes/ directory, defaults/ directory with org.yaml template, defaults/vendors/ directory, and state/deployment/ directory for per-app deployment state.

Parameters:

Name Type Description Default
args Namespace

Parsed command-line arguments containing directory path, force flag, and debug flags.

required

Returns:

Type Description
int

Exit code (0 for success, 1 for failure).

Note

By default, existing files are skipped (not overwritten). Use --force to backup existing files and create fresh ones.

Source code in napt/cli.py
def cmd_init(args: argparse.Namespace) -> int:
    """Handler for 'napt init' command.

    Initializes a new NAPT project by creating the directory structure and
    default configuration files. This command creates the recipes/ directory,
    defaults/ directory with org.yaml template, defaults/vendors/ directory,
    and state/deployment/ directory for per-app deployment state.

    Args:
        args: Parsed command-line arguments containing
            directory path, force flag, and debug flags.

    Returns:
        Exit code (0 for success, 1 for failure).

    Note:
        By default, existing files are skipped (not overwritten).
        Use --force to backup existing files and create fresh ones.

    """
    # Configure global logger
    logger = get_logger(verbose=args.verbose, debug=args.debug)
    set_global_logger(logger)

    target_dir = Path(args.directory).resolve()

    print(f"Initializing NAPT project in: {target_dir}")
    print()

    # Track what we create/skip
    created: list[str] = []
    skipped: list[str] = []
    backed_up: list[str] = []

    # Step 1: Create directory structure
    logger.step(1, 2, "Creating directory structure...")

    # Create recipes/ directory
    recipes_dir = target_dir / "recipes"
    if not recipes_dir.exists():
        recipes_dir.mkdir(parents=True)
        created.append("recipes/")
        logger.verbose("INIT", "Created: recipes/")
    else:
        skipped.append("recipes/")
        logger.verbose("INIT", "Skipped: recipes/ (already exists)")

    # Create defaults/vendors/ directory
    vendors_dir = target_dir / "defaults" / "vendors"
    if not vendors_dir.exists():
        vendors_dir.mkdir(parents=True)
        created.append("defaults/vendors/")
        logger.verbose("INIT", "Created: defaults/vendors/")
    else:
        skipped.append("defaults/vendors/")
        logger.verbose("INIT", "Skipped: defaults/vendors/ (already exists)")

    # Create state/deployment/ directory
    deployment_dir = target_dir / "state" / "deployment"
    if not deployment_dir.exists():
        deployment_dir.mkdir(parents=True)
        created.append("state/deployment/")
        logger.verbose("INIT", "Created: state/deployment/")
    else:
        skipped.append("state/deployment/")
        logger.verbose("INIT", "Skipped: state/deployment/ (already exists)")

    # Step 2: Create configuration files
    logger.step(2, 2, "Creating configuration files...")

    # Create defaults/org.yaml
    org_yaml_path = target_dir / "defaults" / "org.yaml"
    if org_yaml_path.exists():
        if args.force:
            # Backup existing file
            backup_path = org_yaml_path.with_suffix(".yaml.backup")
            org_yaml_path.rename(backup_path)
            backed_up.append(f"defaults/org.yaml -> {backup_path.name}")
            logger.verbose(
                "INIT", f"Backed up: defaults/org.yaml -> {backup_path.name}"
            )

            # Write new file
            org_yaml_path.write_text(ORG_YAML_TEMPLATE, encoding="utf-8")
            created.append("defaults/org.yaml")
            logger.verbose("INIT", "Created: defaults/org.yaml")
        else:
            skipped.append("defaults/org.yaml")
            logger.verbose("INIT", "Skipped: defaults/org.yaml (already exists)")
    else:
        # Ensure parent directory exists
        org_yaml_path.parent.mkdir(parents=True, exist_ok=True)
        org_yaml_path.write_text(ORG_YAML_TEMPLATE, encoding="utf-8")
        created.append("defaults/org.yaml")
        logger.verbose("INIT", "Created: defaults/org.yaml")

    # Display results
    print()
    print("=" * 70)
    print("INITIALIZATION RESULTS")
    print("=" * 70)
    print(f"Project Root:    {target_dir}")
    print()

    if created:
        print(f"Created ({len(created)}):")
        for item in created:
            print(f"  [OK] {item}")
        print()

    if backed_up:
        print(f"Backed Up ({len(backed_up)}):")
        for item in backed_up:
            print(f"  [OK] {item}")
        print()

    if skipped:
        print(f"Skipped ({len(skipped)}):")
        for item in skipped:
            print(f"  [SKIP] {item}")
        print()

    print("=" * 70)
    print()

    if skipped and not args.force:
        print("Note: Existing files were preserved. Use --force to overwrite.")
        print()

    print("[SUCCESS] Project initialized!")
    return 0

main

main() -> None

Main entry point for the napt CLI.

This function is registered as the 'napt' console script in pyproject.toml.

Source code in napt/cli.py
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
def main() -> None:
    """Main entry point for the napt CLI.

    This function is registered as the 'napt' console script in pyproject.toml.
    """
    parser = argparse.ArgumentParser(
        prog="napt",
        description="NAPT - Not a Pkg Tool for Windows/Intune packaging with PSADT",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )

    parser.add_argument(
        "--version",
        action="version",
        version=f"napt {version('napt')}",
    )

    subparsers = parser.add_subparsers(
        dest="command",
        help="Available commands",
        required=True,
    )

    # 'validate' command
    parser_validate = subparsers.add_parser(
        "validate",
        help="Validate recipe syntax and configuration (no downloads)",
        description=(
            "Check recipe YAML for syntax errors and configuration issues "
            "without making network calls.\n\n"
            "Examples:\n"
            "  napt validate recipes/Google/chrome.yaml\n"
            "  napt validate recipes/Google/chrome.yaml --verbose\n\n"
            "See docs for more examples and workflows."
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser_validate.add_argument(
        "recipe",
        help="Path to the recipe YAML file",
    )
    parser_validate.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="Show validation progress and details",
    )
    parser_validate.add_argument(
        "-d",
        "--debug",
        action="store_true",
        help="Show detailed debugging output (implies --verbose)",
    )
    parser_validate.set_defaults(func=cmd_validate)

    # 'discover' command
    parser_discover = subparsers.add_parser(
        "discover",
        help="Discover latest version and download installer",
        description=(
            "Find the latest version using the configured discovery strategy "
            "and download the installer.\n\n"
            "Examples:\n"
            "  napt discover recipes/Google/chrome.yaml\n"
            "  napt discover recipes/Google/chrome.yaml --verbose\n"
            "  napt discover recipes/Google/chrome.yaml --stateless\n\n"
            "See docs for more examples and workflows."
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser_discover.add_argument(
        "recipe",
        help="Path to the recipe YAML file",
    )
    parser_discover.add_argument(
        "--output-dir",
        default=None,
        help="Directory to save downloaded files (default: from config or ./downloads)",
    )
    parser_discover.add_argument(
        "--cache-file",
        type=Path,
        default=None,
        help=(
            "Discovery cache file for version tracking and ETag caching "
            "(default: cache/discovery.json from directories.cache)"
        ),
    )
    parser_discover.add_argument(
        "--state-dir",
        type=Path,
        default=None,
        help=(
            "Directory for per-app deployment state files "
            "(default: state/deployment from directories.state)"
        ),
    )
    parser_discover.add_argument(
        "--stateless",
        action="store_true",
        help=(
            "Disable the discovery cache and deployment state writes "
            "(always download full files, record nothing)"
        ),
    )
    parser_discover.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="Show progress and high-level status updates",
    )
    parser_discover.add_argument(
        "-d",
        "--debug",
        action="store_true",
        help="Show detailed debugging output (implies --verbose)",
    )
    parser_discover.set_defaults(func=cmd_discover)

    # 'build' command
    parser_build = subparsers.add_parser(
        "build",
        help="Build PSADT package from recipe and installer",
        description=(
            "Create a PSADT deployment package from a recipe and "
            "downloaded installer.\n\n"
            "Examples:\n"
            "  napt build recipes/Google/chrome.yaml\n"
            "  napt build recipes/Google/chrome.yaml --verbose\n"
            "  napt build recipes/Google/chrome.yaml --output-dir ./builds\n\n"
            "See docs for more examples and workflows."
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser_build.add_argument(
        "recipe",
        help="Path to the recipe YAML file",
    )
    parser_build.add_argument(
        "--downloads-dir",
        default=None,
        help=(
            "Directory containing the downloaded installer "
            "(default: from config or ./downloads)"
        ),
    )
    parser_build.add_argument(
        "--output-dir",
        default=None,
        help="Base directory for build output (default: from config or ./builds)",
    )
    parser_build.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="Show progress and high-level status updates",
    )
    parser_build.add_argument(
        "-d",
        "--debug",
        action="store_true",
        help="Show detailed debugging output (implies --verbose)",
    )
    parser_build.set_defaults(func=cmd_build)

    # 'package' command
    parser_package = subparsers.add_parser(
        "package",
        help="Create .intunewin package from a PSADT build",
        description=(
            "Package a PSADT build for a recipe into a .intunewin file for "
            "Intune deployment. Without --version, packages the most recently "
            "modified build. Only one packaged version is kept on disk per app "
            "(previous version is removed automatically).\n\n"
            "Examples:\n"
            "  napt package recipes/Google/chrome.yaml\n"
            "  napt package recipes/Google/chrome.yaml --version 130.0.6723.116\n"
            "  napt package recipes/Google/chrome.yaml --clean-source\n"
            "  napt package recipes/Google/chrome.yaml --verbose\n\n"
            "See docs for more examples and workflows."
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser_package.add_argument(
        "recipe",
        help="Path to the recipe YAML file",
    )
    parser_package.add_argument(
        "--version",
        default=None,
        metavar="VERSION",
        help="Specific build version to package (default: most recent build)",
    )
    parser_package.add_argument(
        "--builds-dir",
        default=None,
        help=(
            "Directory containing the PSADT build " "(default: from config or ./builds)"
        ),
    )
    parser_package.add_argument(
        "--output-dir",
        default=None,
        help=(
            "Parent directory for package output "
            "(default: from config or ./packages)"
        ),
    )
    parser_package.add_argument(
        "--clean-source",
        action="store_true",
        help="Remove the build directory after packaging",
    )
    parser_package.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="Show progress and high-level status updates",
    )
    parser_package.add_argument(
        "-d",
        "--debug",
        action="store_true",
        help="Show detailed debugging output (implies --verbose)",
    )
    parser_package.set_defaults(func=cmd_package)

    # 'init' command
    parser_init = subparsers.add_parser(
        "init",
        help="Initialize a new NAPT project",
        description=(
            "Create a new NAPT project structure with default configuration.\n\n"
            "Creates:\n"
            "  - recipes/              Directory for recipe YAML files\n"
            "  - defaults/org.yaml     Organization defaults template\n"
            "  - defaults/vendors/     Directory for vendor-specific defaults\n"
            "  - state/deployment/     Per-app deployment state files\n\n"
            "Examples:\n"
            "  napt init\n"
            "  napt init ./my-project\n"
            "  napt init --force\n\n"
            "See docs for more examples and workflows."
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser_init.add_argument(
        "directory",
        nargs="?",
        default=".",
        help="Directory to initialize (default: current directory)",
    )
    parser_init.add_argument(
        "--force",
        action="store_true",
        help="Backup and overwrite existing configuration files",
    )
    parser_init.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="Show detailed initialization steps",
    )
    parser_init.add_argument(
        "-d",
        "--debug",
        action="store_true",
        help="Show detailed debugging output (implies --verbose)",
    )
    parser_init.set_defaults(func=cmd_init)

    # 'upload' command
    parser_upload = subparsers.add_parser(
        "upload",
        help="Upload .intunewin package to Microsoft Intune",
        description=(
            "Upload the most recent .intunewin package for a recipe to "
            "Microsoft Intune via the Graph API.\n\n"
            "Authentication is automatic — tried in this order:\n"
            "  1. AZURE_CLIENT_ID + AZURE_CLIENT_SECRET + AZURE_TENANT_ID env vars\n"
            "  2. Managed identity (Azure VMs, GitHub Actions OIDC)\n"
            "  3. Device code flow (browser login — set AZURE_CLIENT_ID + AZURE_TENANT_ID)\n\n"
            "Examples:\n"
            "  napt upload recipes/Google/chrome.yaml\n"
            "  napt upload recipes/Google/chrome.yaml --tenant-id <id>\n"
            "  napt upload recipes/Google/chrome.yaml --verbose\n\n"
            "See docs for auth setup and full configuration guide."
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser_upload.add_argument(
        "recipe",
        help="Path to the recipe YAML file",
    )
    parser_upload.add_argument(
        "--tenant-id",
        default=None,
        help="Azure AD tenant ID (overrides defaults/org.yaml)",
    )
    parser_upload.add_argument(
        "--force",
        action="store_true",
        help=(
            "Re-upload metadata and content to existing NAPT-managed apps "
            "for this release instead of adopting them as-is "
            "(never creates duplicates)"
        ),
    )
    parser_upload.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="Show progress and high-level status updates",
    )
    parser_upload.add_argument(
        "-d",
        "--debug",
        action="store_true",
        help="Show detailed debugging output (implies --verbose)",
    )
    parser_upload.set_defaults(func=cmd_upload)

    # 'promote' command with subcommands
    parser_promote = subparsers.add_parser(
        "promote",
        help="Plan and apply deployment ring promotion",
        description=(
            "Plan and apply ring-based promotion of published apps.\n\n"
            "Examples:\n"
            "  napt promote plan\n"
            "  napt promote apply\n"
            "  napt promote plan recipes/Google/chrome.yaml\n\n"
            "See docs for the promotion model and workflows."
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    promote_sub = parser_promote.add_subparsers(
        dest="subcommand",
        help="Promotion subcommands",
        required=True,
    )
    parser_promote_plan = promote_sub.add_parser(
        "plan",
        help="Compute eligible promotions and write per-app plan files",
        description=(
            "Compute which releases are ready to promote through deployment "
            "rings, and write one state/plans/<app>.json file per app with "
            "work. "
            "Never modifies Intune. Read-only for deployment state too, "
            "except that --reconcile writes it when recovering a lost "
            "publication writeback. With --check-drift or --reconcile, "
            "every group in the plan is validated against Entra ID and an "
            "unresolvable group fails the run without writing any plan. "
            "An app's stale plan file is removed when nothing is eligible "
            "for it."
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser_promote_plan.add_argument(
        "recipes",
        nargs="?",
        default="recipes",
        help="Recipe file or directory to plan for (default: recipes/)",
    )
    parser_promote_plan.add_argument(
        "--state-dir",
        type=Path,
        default=None,
        help=(
            "State directory holding deployment/ and plans/ "
            "(default: directories.state from config)"
        ),
    )
    parser_promote_plan.add_argument(
        "--check-drift",
        action="store_true",
        help=(
            "Also compare Intune assignments against deployment state "
            "(requires Graph credentials); findings are warnings only"
        ),
    )
    parser_promote_plan.add_argument(
        "--reconcile",
        action="store_true",
        help=(
            "Before planning, record publications that are committed in "
            "Intune but whose deployment state writeback was lost, so "
            "they are promotable in this run (requires Graph credentials; "
            "writes deployment state)"
        ),
    )
    parser_promote_plan.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="Show progress and high-level status updates",
    )
    parser_promote_plan.add_argument(
        "-d",
        "--debug",
        action="store_true",
        help="Show detailed debugging output (implies --verbose)",
    )
    parser_promote_plan.set_defaults(func=cmd_promote_plan)

    parser_promote_apply = promote_sub.add_parser(
        "apply",
        help="Execute promotion plans against Intune",
        description=(
            "Execute promotion actions: assign install entries, promote "
            "releases through rings, displace the older releases they "
            "replace, and retire them per deployment.retain_versions. "
            "Consumes each per-app plan "
            "file in state/plans/ when any exist; otherwise plans fresh "
            "and applies immediately. One app's failure keeps its plan "
            "file and never blocks the others, and stale or "
            "already-applied actions are skipped, so re-running is safe."
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser_promote_apply.add_argument(
        "recipes",
        nargs="?",
        default="recipes",
        help="Recipe file or directory to apply for (default: recipes/)",
    )
    parser_promote_apply.add_argument(
        "--state-dir",
        type=Path,
        default=None,
        help=(
            "State directory holding deployment/ and plans/ "
            "(default: directories.state from config)"
        ),
    )
    parser_promote_apply.add_argument(
        "--plan-file",
        type=Path,
        default=None,
        help=(
            "Single plan file to execute (default: every file in "
            "<state-dir>/plans/ if any exist)"
        ),
    )
    parser_promote_apply.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="Show progress and high-level status updates",
    )
    parser_promote_apply.add_argument(
        "-d",
        "--debug",
        action="store_true",
        help="Show detailed debugging output (implies --verbose)",
    )
    parser_promote_apply.set_defaults(func=cmd_promote_apply)

    # 'status' command
    parser_status = subparsers.add_parser(
        "status",
        help="Show deployment state across all apps",
        description=(
            "Aggregate per-app deployment state into one view: published "
            "version, pending release, and ring positions.\n\n"
            "Examples:\n"
            "  napt status\n"
            "  napt status --format json\n\n"
            "See docs for more examples and workflows."
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser_status.add_argument(
        "--state-dir",
        type=Path,
        default=Path("state"),
        help="State directory holding deployment/ (default: state)",
    )
    parser_status.add_argument(
        "--format",
        choices=["text", "json"],
        default="text",
        help="Output format (default: text)",
    )
    parser_status.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="Show progress and high-level status updates",
    )
    parser_status.add_argument(
        "-d",
        "--debug",
        action="store_true",
        help="Show detailed debugging output (implies --verbose)",
    )
    parser_status.set_defaults(func=cmd_status)

    # Parse and dispatch
    args = parser.parse_args()

    # Call the appropriate command handler
    exit_code = args.func(args)
    sys.exit(exit_code)