Skip to content

cli

napt.cli

Command-line interface for NAPT.

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
auth: Sign in to Microsoft Graph and inspect credentials
promote: Plan and apply deployment ring promotion
status: Show deployment state across all apps

Each command lives in its own module named after it -- napt/cli/validate.py owns napt validate -- holding the command's cmd_* handlers and a register(subparsers) hook that adds its parser. napt/cli/main.py assembles the top-level parser, calls each command's register, and dispatches to the selected handler.

Exit Codes:

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

The CLI uses argparse for command parsing (stdlib, zero dependencies). Verbose mode shows full tracebacks on errors for debugging. Debug mode implies verbose mode and shows detailed configuration dumps.

napt.cli.main

CLI entry point: parser assembly and dispatch.

Builds the top-level napt argument parser, calls each command module's register hook to add its subparser, and dispatches to the selected command's handler. Registered as the napt console script in pyproject.toml.

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/main.py
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 {get_version()}",
    )

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

    validate.register(subparsers)
    discover.register(subparsers)
    build.register(subparsers)
    package.register(subparsers)
    init.register(subparsers)
    upload.register(subparsers)
    auth.register(subparsers)
    promote.register(subparsers)
    status.register(subparsers)

    # Parse and dispatch
    args = parser.parse_args()

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

napt.cli.auth

The napt auth command.

Manages the credential NAPT uses for Intune through the login, logout, status, and setup subcommands.

cmd_auth_login

cmd_auth_login(args: Namespace) -> int

Handler for 'napt auth login' command.

Signs in interactively through the OS broker or the browser and caches the session so later commands authenticate silently.

Parameters:

Name Type Description Default
args Namespace

Parsed command-line arguments containing optional client and tenant IDs and the --no-broker flag.

required

Returns:

Type Description
int

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

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

    Signs in interactively through the OS broker or the browser and caches
    the session so later commands authenticate silently.

    Args:
        args: Parsed command-line arguments containing optional client and
            tenant IDs and the --no-broker flag.

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

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

    try:
        status = auth_login(
            client_id=args.client_id,
            tenant_id=args.tenant_id,
            use_broker=not args.no_broker,
        )
    except (AuthError, ConfigError) as err:
        print(f"Authentication error: {err}")
        if args.verbose or args.debug:
            import traceback

            traceback.print_exc()
        return 1

    print()
    print(f"[OK] Signed in as {status.account or '(unknown account)'}")
    print()
    _print_auth_status(status)
    return 0

cmd_auth_logout

cmd_auth_logout(args: Namespace) -> int

Handler for 'napt auth logout' command.

Removes the active tenant's cached session, or every tenant's with --all. Client and tenant IDs are kept for the next login.

Parameters:

Name Type Description Default
args Namespace

Parsed command-line arguments containing the --all flag and debug flags.

required

Returns:

Type Description
int

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

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

    Removes the active tenant's cached session, or every tenant's with
    --all. Client and tenant IDs are kept for the next login.

    Args:
        args: Parsed command-line arguments containing the --all flag and
            debug flags.

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

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

    try:
        removed = auth_logout(all_tenants=args.all)
    except (AuthError, ConfigError) as err:
        print(f"Authentication error: {err}")
        if args.verbose or args.debug:
            import traceback

            traceback.print_exc()
        return 1

    if removed:
        print(
            f"[OK] Signed out of {len(removed)} tenant(s): {', '.join(removed)}. "
            "Run 'napt auth login' to sign in again."
        )
    else:
        print("No interactive session to sign out of.")
    return 0

cmd_auth_status

cmd_auth_status(args: Namespace) -> int

Handler for 'napt auth status' command.

Shows which credential NAPT would use right now -- the same resolution 'napt upload' performs -- and flags missing Graph permissions.

Parameters:

Name Type Description Default
args Namespace

Parsed command-line arguments containing debug flags.

required

Returns:

Type Description
int

Exit code (0 when a credential is available, 1 otherwise).

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

    Shows which credential NAPT would use right now -- the same resolution
    'napt upload' performs -- and flags missing Graph permissions.

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

    Returns:
        Exit code (0 when a credential is available, 1 otherwise).

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

    try:
        status = auth_status()
    except (AuthError, ConfigError) as err:
        print(f"Authentication error: {err}")
        if args.verbose or args.debug:
            import traceback

            traceback.print_exc()
        return 1

    if status is None:
        print("Not authenticated.")
        print()
        print("  Interactive:  run 'napt auth login'")
        print("  CI/CD:        set AZURE_CLIENT_ID, AZURE_TENANT_ID and either")
        print("                AZURE_CLIENT_SECRET / AZURE_CLIENT_CERTIFICATE_PATH,")
        print("                or sign in with 'az login' (e.g. azure/login in CI)")
        _print_known_tenants()
        return 1

    _print_auth_status(status)
    if status.method.startswith("interactive"):
        _print_known_tenants()
    return 0 if not status.missing else 1

cmd_auth_setup

cmd_auth_setup(args: Namespace) -> int

Handler for 'napt auth setup' command.

Creates or completes the NAPT app registration in a tenant through Microsoft Graph, or with --print-only prints the equivalent portal checklist without signing in.

Parameters:

Name Type Description Default
args Namespace

Parsed command-line arguments containing the tenant ID, optional name, client ID, federated credential settings, and flags.

required

Returns:

Type Description
int

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

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

    Creates or completes the NAPT app registration in a tenant through
    Microsoft Graph, or with --print-only prints the equivalent portal
    checklist without signing in.

    Args:
        args: Parsed command-line arguments containing the tenant ID,
            optional name, client ID, federated credential settings, and flags.

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

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

    try:
        spec = SetupSpec(
            tenant_id=args.tenant_id,
            display_name=args.name,
            client_id=args.client_id,
            federated_issuer=args.federated_issuer,
            federated_subject=args.federated_subject,
            federated_audience=args.federated_audience,
            federated_name=args.federated_name,
            adopt=args.adopt,
        )
    except ConfigError as err:
        print(f"Error: {err}")
        return 1

    if args.print_only:
        _print_setup_checklist(spec)
        return 0

    try:
        result = setup_app_registration(spec)
    except (AuthError, ConfigError, NetworkError) as err:
        print(f"Error: {err}")
        if args.verbose or args.debug:
            import traceback

            traceback.print_exc()
        return 1

    print()
    if result.needs_adopt:
        print(
            f"[WARNING] Found existing registration '{result.display_name}' "
            f"({result.client_id}) that NAPT did not create."
        )
        print("          Re-run with --adopt to manage it. Adopting adds NAPT's")
        print("          redirect URIs, Microsoft Graph permissions, and admin")
        print("          consent, and stamps the registration's internal notes;")
        print("          it never removes existing settings.")
        print(
            "          To create a new registration instead, re-run with "
            f"--name <a name other than '{result.display_name}'>."
        )
        return 1

    if result.adopted:
        print(
            f"[OK] Adopted '{result.display_name}' ({result.client_id}) -- "
            f"stamped as napt/v1 spec={SPEC_VERSION}. Changes made:"
        )
    elif result.changes:
        print("[OK] App registration is ready. Changes made:")
    else:
        print(
            f"[OK] App registration '{result.display_name}' is at spec "
            f"{SPEC_VERSION}; nothing to change."
        )
    for change in result.changes:
        print(f"  - {change}")
    print()
    print(f"Name:       {result.display_name}")
    print(f"Tenant ID:  {result.tenant_id}")
    print(f"Client ID:  {result.client_id}")
    print()
    print("Next steps:")
    print("  Interactive: napt auth login")
    print("  CI/CD:       set AZURE_TENANT_ID and AZURE_CLIENT_ID to the values above")
    if spec.federated_subject:
        print("               and let your CI platform's OIDC login mint the token")
        print("               (e.g. azure/login on GitHub Actions) -- no secret needed")
    else:
        print("               plus AZURE_CLIENT_SECRET, or re-run with")
        print("               --federated-issuer/--federated-subject to add an OIDC")
        print("               federated credential instead")
    return 0

register

register(subparsers: _SubParsersAction) -> None

Registers the 'auth' command parser and its subcommands.

Parameters:

Name Type Description Default
subparsers _SubParsersAction

The CLI's subparsers action to add the command to.

required
Source code in napt/cli/auth.py
def register(subparsers: argparse._SubParsersAction) -> None:
    """Registers the 'auth' command parser and its subcommands.

    Args:
        subparsers: The CLI's subparsers action to add the command to.
    """
    parser_auth = subparsers.add_parser(
        "auth",
        help="Sign in to Microsoft Graph and inspect credentials",
        description=(
            "Manage the credential NAPT uses for Intune.\n\n"
            "Examples:\n"
            "  napt auth setup --tenant-id <id>\n"
            "  napt auth login --tenant-id <id> --client-id <id>\n"
            "  napt auth login\n"
            "  napt auth status\n"
            "  napt auth logout\n\n"
            "See docs for app registration setup."
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    auth_sub = parser_auth.add_subparsers(
        dest="subcommand",
        help="Auth subcommands",
        required=True,
    )

    parser_auth_login = auth_sub.add_parser(
        "login",
        help="Sign in interactively (browser or OS broker)",
        description=(
            "Sign in interactively and cache the session so later commands\n"
            "authenticate silently. Uses the Windows broker (WAM) when\n"
            "available, otherwise the system browser.\n\n"
            "The tenant, client ID, and account are remembered after the first\n"
            "login. Pass --tenant-id to switch between signed-in tenants (no\n"
            "prompt when that tenant's session is still valid)."
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser_auth_login.add_argument(
        "--tenant-id",
        default=None,
        help=(
            "Directory (tenant) ID, or the default domain of a tenant you have "
            "signed in to before (defaults to the active tenant)"
        ),
    )
    parser_auth_login.add_argument(
        "--client-id",
        default=None,
        help=(
            "Application (client) ID of the NAPT app registration "
            "(needed the first time you sign in to a tenant)"
        ),
    )
    parser_auth_login.add_argument(
        "--no-broker",
        action="store_true",
        help="Use the browser even when the OS broker is available",
    )
    parser_auth_login.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="Show progress and high-level status updates",
    )
    parser_auth_login.add_argument(
        "-d",
        "--debug",
        action="store_true",
        help="Show detailed debugging output (implies --verbose)",
    )
    parser_auth_login.set_defaults(func=cmd_auth_login)

    parser_auth_logout = auth_sub.add_parser(
        "logout",
        help="Remove the cached interactive session",
        description=(
            "Sign out of the active tenant's cached session (or every "
            "tenant's with --all)."
        ),
    )
    parser_auth_logout.add_argument(
        "--all",
        action="store_true",
        help="Sign out of every remembered tenant",
    )
    parser_auth_logout.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="Show progress and high-level status updates",
    )
    parser_auth_logout.add_argument(
        "-d",
        "--debug",
        action="store_true",
        help="Show detailed debugging output (implies --verbose)",
    )
    parser_auth_logout.set_defaults(func=cmd_auth_logout)

    parser_auth_status = auth_sub.add_parser(
        "status",
        help="Show which credential NAPT would use and its permissions",
        description=(
            "Show the credential NAPT would use right now (the same resolution\n"
            "'napt upload' performs), the account and tenant it belongs to, and\n"
            "the Graph permissions it carries. Exits 1 when no credential is\n"
            "available or a required permission is missing."
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser_auth_status.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="Show progress and high-level status updates",
    )
    parser_auth_status.add_argument(
        "-d",
        "--debug",
        action="store_true",
        help="Show detailed debugging output (implies --verbose)",
    )
    parser_auth_status.set_defaults(func=cmd_auth_status)

    parser_auth_setup = auth_sub.add_parser(
        "setup",
        help="Create or complete the NAPT app registration in a tenant",
        description=(
            "Create the NAPT app registration in Microsoft Entra ID, or bring an\n"
            "existing one up to spec: redirect URIs, Microsoft Graph permissions\n"
            "(application and delegated), service principal, and admin consent.\n"
            "Optionally adds a federated credential so a CI/CD platform can obtain\n"
            "tokens through OIDC without a client secret.\n\n"
            "Requires an account holding at least the Application Administrator\n"
            "role. NAPT does not store that account or its tokens (your browser\n"
            "may keep its own sign-in). Re-running is safe: NAPT compares the\n"
            "registration with what this version needs and adds what is missing,\n"
            "never removing anything.\n\n"
            "Examples:\n"
            "  napt auth setup --tenant-id <id>\n"
            "  napt auth setup --tenant-id <id> \\\n"
            "      --federated-issuer https://token.actions.githubusercontent.com \\\n"
            "      --federated-subject repo:contoso/intune-apps:ref:refs/heads/main\n"
            "  napt auth setup --tenant-id <id> --print-only"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser_auth_setup.add_argument(
        "--tenant-id",
        required=True,
        help="Directory (tenant) ID to provision in",
    )
    parser_auth_setup.add_argument(
        "--name",
        default="NAPT",
        help="Display name of the app registration to find or create (default: NAPT)",
    )
    parser_auth_setup.add_argument(
        "--client-id",
        default=None,
        help="Bring this existing registration up to spec instead of matching by name",
    )
    parser_auth_setup.add_argument(
        "--federated-issuer",
        default=None,
        metavar="URL",
        help=(
            "OIDC issuer of a CI platform to trust, e.g. "
            "https://token.actions.githubusercontent.com (requires --federated-subject)"
        ),
    )
    parser_auth_setup.add_argument(
        "--federated-subject",
        default=None,
        metavar="SUBJECT",
        help=(
            "Subject claim the platform presents for the trusted workflow, in the "
            "platform's format, e.g. repo:owner/name:ref:refs/heads/main"
        ),
    )
    parser_auth_setup.add_argument(
        "--federated-audience",
        default=FEDERATED_AUDIENCE_DEFAULT,
        metavar="AUDIENCE",
        help=f"Audience claim (default: {FEDERATED_AUDIENCE_DEFAULT})",
    )
    parser_auth_setup.add_argument(
        "--federated-name",
        default=None,
        metavar="NAME",
        help="Name of the federated credential (default: derived from the subject)",
    )
    parser_auth_setup.add_argument(
        "--adopt",
        action="store_true",
        help=(
            "Manage a registration matched by name that NAPT did not create "
            "(adds what is missing, never removes anything)"
        ),
    )
    parser_auth_setup.add_argument(
        "--print-only",
        action="store_true",
        help="Print the portal checklist instead of changing anything",
    )
    parser_auth_setup.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="Show progress and high-level status updates",
    )
    parser_auth_setup.add_argument(
        "-d",
        "--debug",
        action="store_true",
        help="Show detailed debugging output (implies --verbose)",
    )
    parser_auth_setup.set_defaults(func=cmd_auth_setup)

napt.cli.build

The napt build command.

Creates a PSADT deployment package from a recipe and a downloaded installer.

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/build.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,
            state_dir=args.state_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

register

register(subparsers: _SubParsersAction) -> None

Registers the 'build' command parser.

Parameters:

Name Type Description Default
subparsers _SubParsersAction

The CLI's subparsers action to add the command to.

required
Source code in napt/cli/build.py
def register(subparsers: argparse._SubParsersAction) -> None:
    """Registers the 'build' command parser.

    Args:
        subparsers: The CLI's subparsers action to add the command to.
    """
    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(
        "--state-dir",
        type=Path,
        default=None,
        help=(
            "State root; the release to build is read from <dir>/deployment/ "
            "(default: directories.state, ./state)"
        ),
    )
    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)

napt.cli.discover

The napt discover command.

Finds the latest version of an application with the configured discovery strategy and downloads the installer.

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 reuses the one an earlier run downloaded), extracts version information, 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, 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 reuses an earlier download). Updates 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/discover.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 reuses the one an earlier run downloaded),
    extracts version information, 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, deployment state directory,
            and flags.

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

    Note:
        Downloads installer file to output_dir (or reuses an earlier
        download). Updates 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,
            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

register

register(subparsers: _SubParsersAction) -> None

Registers the 'discover' command parser.

Parameters:

Name Type Description Default
subparsers _SubParsersAction

The CLI's subparsers action to add the command to.

required
Source code in napt/cli/discover.py
def register(subparsers: argparse._SubParsersAction) -> None:
    """Registers the 'discover' command parser.

    Args:
        subparsers: The CLI's subparsers action to add the command to.
    """
    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(
        "--state-dir",
        type=Path,
        default=None,
        help=(
            "State root; deployment state is written to <dir>/deployment/ "
            "(default: directories.state, ./state)"
        ),
    )
    parser_discover.add_argument(
        "--stateless",
        action="store_true",
        help="Do not read or write deployment state (no pending release is recorded)",
    )
    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)

napt.cli.init

The napt init command.

Creates a new NAPT project structure with default configuration.

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/init.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

register

register(subparsers: _SubParsersAction) -> None

Registers the 'init' command parser.

Parameters:

Name Type Description Default
subparsers _SubParsersAction

The CLI's subparsers action to add the command to.

required
Source code in napt/cli/init.py
def register(subparsers: argparse._SubParsersAction) -> None:
    """Registers the 'init' command parser.

    Args:
        subparsers: The CLI's subparsers action to add the command to.
    """
    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)

napt.cli.package

The napt package command.

Packages a PSADT build into a .intunewin file for Intune deployment.

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/package.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

register

register(subparsers: _SubParsersAction) -> None

Registers the 'package' command parser.

Parameters:

Name Type Description Default
subparsers _SubParsersAction

The CLI's subparsers action to add the command to.

required
Source code in napt/cli/package.py
def register(subparsers: argparse._SubParsersAction) -> None:
    """Registers the 'package' command parser.

    Args:
        subparsers: The CLI's subparsers action to add the command to.
    """
    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)

napt.cli.promote

The napt promote command.

Plans and applies ring-based promotion of published apps through the plan and apply subcommands.

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/promote.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/promote.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

register

register(subparsers: _SubParsersAction) -> None

Registers the 'promote' command parser and its subcommands.

Parameters:

Name Type Description Default
subparsers _SubParsersAction

The CLI's subparsers action to add the command to.

required
Source code in napt/cli/promote.py
def register(subparsers: argparse._SubParsersAction) -> None:
    """Registers the 'promote' command parser and its subcommands.

    Args:
        subparsers: The CLI's subparsers action to add the command to.
    """
    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)

napt.cli.status

The napt status command.

Aggregates per-app deployment state into one view: published version, pending release, and ring positions.

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/status.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 "-")
            + (" [DOWNGRADE]" if row["pending_is_downgrade"] else ""),
            ", ".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

register

register(subparsers: _SubParsersAction) -> None

Registers the 'status' command parser.

Parameters:

Name Type Description Default
subparsers _SubParsersAction

The CLI's subparsers action to add the command to.

required
Source code in napt/cli/status.py
def register(subparsers: argparse._SubParsersAction) -> None:
    """Registers the 'status' command parser.

    Args:
        subparsers: The CLI's subparsers action to add the command to.
    """
    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)

napt.cli.upload

The napt upload command.

Uploads the packaged .intunewin file for a recipe to Microsoft Intune via the Graph API.

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 uses service principal / OIDC environment variables when set, otherwise the session saved by 'napt auth login'.

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: run 'napt auth login' once. CI/CD: set AZURE_CLIENT_ID, AZURE_TENANT_ID and AZURE_CLIENT_SECRET, or use OIDC federation.

Source code in napt/cli/upload.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
    uses service principal / OIDC environment variables when set, otherwise
    the session saved by 'napt auth login'.

    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: run 'napt auth login' once. CI/CD: set AZURE_CLIENT_ID,
        AZURE_TENANT_ID and AZURE_CLIENT_SECRET, or use OIDC federation.

    """
    # 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

register

register(subparsers: _SubParsersAction) -> None

Registers the 'upload' command parser.

Parameters:

Name Type Description Default
subparsers _SubParsersAction

The CLI's subparsers action to add the command to.

required
Source code in napt/cli/upload.py
def register(subparsers: argparse._SubParsersAction) -> None:
    """Registers the 'upload' command parser.

    Args:
        subparsers: The CLI's subparsers action to add the command to.
    """
    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:\n"
            "  CI/CD:       AZURE_CLIENT_ID + AZURE_TENANT_ID + AZURE_CLIENT_SECRET,\n"
            "               or OIDC federation (azure/login)\n"
            "  Interactive: run 'napt auth login' once\n\n"
            "Examples:\n"
            "  napt upload recipes/Google/chrome.yaml\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(
        "--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)

napt.cli.validate

The napt validate command.

Checks recipe YAML for syntax errors and configuration issues without downloading files or making network calls.

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/validate.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}")
    if result.parent_path:
        print(f"Parent:      {result.parent_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 NAPTError as err:
            # An invalid recipe cannot be merged; say so rather than hide it.
            print()
            print(f"Provenance unavailable: {err}")

    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

register

register(subparsers: _SubParsersAction) -> None

Registers the 'validate' command parser.

Parameters:

Name Type Description Default
subparsers _SubParsersAction

The CLI's subparsers action to add the command to.

required
Source code in napt/cli/validate.py
def register(subparsers: argparse._SubParsersAction) -> None:
    """Registers the 'validate' command parser.

    Args:
        subparsers: The CLI's subparsers action to add the command to.
    """
    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)