diff --git a/craft_application/commands/__init__.py b/craft_application/commands/__init__.py
index d199335b1..476ae6fd6 100644
--- a/craft_application/commands/__init__.py
+++ b/craft_application/commands/__init__.py
@@ -20,12 +20,14 @@
from .init import InitCommand
from .lifecycle import get_lifecycle_command_group, LifecycleCommand, TestCommand
from .other import get_other_command_group
+from .prune_instances import PruneInstancesCommand
from .remote import RemoteBuild # Not part of the default commands.
__all__ = [
"AppCommand",
"ExtensibleCommand",
"InitCommand",
+ "PruneInstancesCommand",
"RemoteBuild",
"lifecycle",
"LifecycleCommand",
diff --git a/craft_application/commands/other.py b/craft_application/commands/other.py
index 38b6de590..4c15a3bee 100644
--- a/craft_application/commands/other.py
+++ b/craft_application/commands/other.py
@@ -20,6 +20,7 @@
from craft_cli import CommandGroup, emit
from . import InitCommand, base
+from .prune_instances import PruneInstancesCommand
if TYPE_CHECKING: # pragma: no cover
import argparse
@@ -29,6 +30,7 @@ def get_other_command_group() -> CommandGroup:
"""Return the lifecycle related command group."""
commands: list[type[base.AppCommand]] = [
InitCommand,
+ PruneInstancesCommand,
VersionCommand,
]
diff --git a/craft_application/commands/prune_instances.py b/craft_application/commands/prune_instances.py
new file mode 100644
index 000000000..0ae3500d4
--- /dev/null
+++ b/craft_application/commands/prune_instances.py
@@ -0,0 +1,59 @@
+# Copyright 2026 Canonical Ltd.
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Lesser General Public License version 3, as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE.
+# See the GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License along
+# with this program. If not, see .
+"""Provider-related commands."""
+
+from __future__ import annotations
+
+import textwrap
+from typing import TYPE_CHECKING
+
+from . import base
+
+if TYPE_CHECKING: # pragma: no cover
+ import argparse
+
+
+class PruneInstancesCommand(base.AppCommand):
+ """Prune instances for the active provider."""
+
+ name = "prune-instances"
+ help_msg = "Prune instances for the selected provider or all providers"
+ overview = textwrap.dedent(
+ """
+ Prune instances using the standard provider-selection logic.
+ Use --provider to prune instances for a specific provider, or
+ --all-providers to prune instances from all providers.
+ """
+ )
+
+ def _fill_parser(self, parser: argparse.ArgumentParser) -> None:
+ provider_group = parser.add_mutually_exclusive_group()
+ provider_group.add_argument(
+ "--all-providers",
+ action="store_true",
+ help="Prune instances from all providers",
+ )
+ provider_group.add_argument(
+ "--provider",
+ help="Prune instances for a specific provider; omit to use the standard provider-selection logic",
+ choices=["lxd", "multipass"],
+ )
+
+ def run(self, parsed_args: argparse.Namespace) -> None:
+ """Run the prune-instances command."""
+ self._services.provider.prune_instances(
+ all_providers=parsed_args.all_providers,
+ provider_name=parsed_args.provider,
+ prune_templates=parsed_args.prune_templates,
+ )
diff --git a/craft_application/services/provider.py b/craft_application/services/provider.py
index 0bfae3f8d..7551e6455 100644
--- a/craft_application/services/provider.py
+++ b/craft_application/services/provider.py
@@ -597,3 +597,54 @@ def prepare_instance(instance: craft_providers.Executor) -> None:
finally:
if active_fetch_service:
self._services.get("fetch").teardown_instance()
+
+ def prune_instances(
+ self,
+ *,
+ all_providers: bool = False,
+ provider_name: str | None = None,
+ prune_templates: bool = False,
+ ) -> None:
+ """Prune instances and optionally templates for the provider(s).
+
+ :param all_providers: Whether to prune instances for all providers or just the
+ current provider.
+ :param provider_name: Optional name of the provider to prune (if all_providers
+ is False).
+ :param prune_templates: Whether to also prune templates (if supported by the
+ provider).
+ """
+ providers: list[craft_providers.Provider] = []
+
+ if provider_name:
+ providers.append(self._get_provider_by_name(name=provider_name))
+ elif all_providers:
+ self.add_provider_if_available(
+ providers, "lxd", "LXD provider not available, skipping LXD pruning."
+ )
+ self.add_provider_if_available(
+ providers,
+ "multipass",
+ "Multipass provider not available, skipping multipass pruning.",
+ )
+ else:
+ providers.append(self.get_provider(name=provider_name))
+
+ for provider in providers:
+ emit.progress(f"Pruning instances for provider {provider.name!r}...")
+ provider.prune(prune_templates=prune_templates)
+
+ def add_provider_if_available(
+ self,
+ providers: list[craft_providers.Provider],
+ provider_key: str,
+ unavailable_message: str,
+ ) -> None:
+ """Add provider to the providers list if it is available, otherwise logs the unavailable_message."""
+ try:
+ provider = self._get_provider_by_name(provider_key)
+ provider.ensure_provider_is_available()
+ except (craft_providers.ProviderError, RuntimeError):
+ emit.debug(unavailable_message)
+ return
+ providers.append(provider)
diff --git a/tests/spread/testcraft/prune-instances/task.yaml b/tests/spread/testcraft/prune-instances/task.yaml
new file mode 100644
index 000000000..16202c8f0
--- /dev/null
+++ b/tests/spread/testcraft/prune-instances/task.yaml
@@ -0,0 +1,23 @@
+summary: Testcraft should prune its instances
+
+systems:
+ - ubuntu-24.04-64
+
+execute: |
+ # Create a LXD instance.
+ testcraft pull --use-lxd
+
+ # Verify the instance exists. The name should start with 'testcraft-prune-instances-test-'.
+ lxc list --format csv -c n | grep "^testcraft-prune-instances-test-"
+
+ # Prune instances.
+ testcraft prune-instances --all-providers
+
+ # Verify the instance is gone.
+ if lxc list --format csv -c n | grep "^testcraft-prune-instances-test-"; then
+ echo "Instance was not pruned!"
+ exit 1
+ fi
+
+restore: |
+ testcraft prune-instances --all-providers
diff --git a/tests/spread/testcraft/prune-instances/testcraft.yaml b/tests/spread/testcraft/prune-instances/testcraft.yaml
new file mode 100644
index 000000000..d2a150b77
--- /dev/null
+++ b/tests/spread/testcraft/prune-instances/testcraft.yaml
@@ -0,0 +1,11 @@
+name: prune-instances-test
+summary: test summary
+version: "0.1"
+
+base: ubuntu@24.04
+platforms:
+ amd64:
+
+parts:
+ my-part:
+ plugin: nil
diff --git a/tests/unit/commands/test_other.py b/tests/unit/commands/test_other.py
index d8a936ff0..25cd8abc7 100644
--- a/tests/unit/commands/test_other.py
+++ b/tests/unit/commands/test_other.py
@@ -18,10 +18,10 @@
import argparse
import pytest
-from craft_application.commands import InitCommand
+from craft_application.commands import InitCommand, PruneInstancesCommand
from craft_application.commands.other import VersionCommand, get_other_command_group
-OTHER_COMMANDS = {InitCommand, VersionCommand}
+OTHER_COMMANDS = {InitCommand, VersionCommand, PruneInstancesCommand}
@pytest.mark.parametrize("commands", [OTHER_COMMANDS])
diff --git a/tests/unit/commands/test_prune_instances.py b/tests/unit/commands/test_prune_instances.py
new file mode 100644
index 000000000..4be1f814e
--- /dev/null
+++ b/tests/unit/commands/test_prune_instances.py
@@ -0,0 +1,36 @@
+# This file is part of craft-application.
+#
+# Copyright 2026 Canonical Ltd.
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Lesser General Public License version 3, as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE.
+# See the GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License along
+# with this program. If not, see .
+"""Tests for prune-instances command."""
+
+import argparse
+
+from craft_application.commands import PruneInstancesCommand
+
+
+def test_prune_instances_run(app_metadata, mock_services):
+ parsed_args = argparse.Namespace(
+ all_providers=True,
+ provider="lxd",
+ prune_templates=True,
+ )
+ command = PruneInstancesCommand({"app": app_metadata, "services": mock_services})
+ command.run(parsed_args)
+
+ mock_services.provider.prune_instances.assert_called_once_with(
+ all_providers=True,
+ provider_name="lxd",
+ prune_templates=True,
+ )
diff --git a/tests/unit/services/test_provider.py b/tests/unit/services/test_provider.py
index a2fb5311b..5d68bff27 100644
--- a/tests/unit/services/test_provider.py
+++ b/tests/unit/services/test_provider.py
@@ -1174,3 +1174,66 @@ def test_configure_instance_with_pro_skipped(mocker, provider_service):
mock_instance.install_pro_client.assert_not_called()
mock_instance.attach_pro_subscription.assert_not_called()
mock_instance.enable_pro_service.assert_not_called()
+
+
+def test_prune_instances_single_provider(monkeypatch, provider_service):
+ """Prune for a single provider."""
+ mock_lxd = mock.MagicMock()
+ mock_lxd.name = "lxd"
+
+ monkeypatch.setattr(provider_service, "get_provider", lambda name: mock_lxd)
+
+ provider_service.prune_instances(provider_name="lxd", prune_templates=True)
+
+ mock_lxd.prune.assert_called_once_with(prune_templates=True)
+
+
+def test_prune_instances_none_provider(monkeypatch, provider_service):
+ """Prune for a single provider."""
+ mock_lxd = mock.MagicMock()
+ mock_lxd.name = "lxd"
+
+ monkeypatch.setattr(provider_service, "get_provider", lambda name: mock_lxd)
+
+ provider_service.prune_instances(provider_name=None, prune_templates=True)
+
+ mock_lxd.prune.assert_called_once_with(prune_templates=True)
+
+
+def test_prune_instances_all_providers(monkeypatch, provider_service):
+ """Prune for all supported providers."""
+ mock_lxd = mock.MagicMock()
+ mock_lxd.name = "lxd"
+ mock_multipass = mock.MagicMock()
+ mock_multipass.name = "multipass"
+
+ def mock_get_by_name(name):
+ if name == "lxd":
+ return mock_lxd
+ if name == "multipass":
+ return mock_multipass
+ raise RuntimeError(f"Unknown provider: {name}")
+
+ monkeypatch.setattr(provider_service, "_get_provider_by_name", mock_get_by_name)
+
+ provider_service.prune_instances(all_providers=True, prune_templates=False)
+
+ mock_lxd.prune.assert_called_once_with(prune_templates=False)
+ mock_multipass.prune.assert_called_once_with(prune_templates=False)
+
+
+def test_prune_instances_all_providers_skips_unsupported(monkeypatch, provider_service):
+ """Prune for all supported providers, skipping those not supported on the host."""
+ mock_lxd = mock.MagicMock()
+ mock_lxd.name = "lxd"
+
+ def mock_get_by_name(name):
+ if name == "lxd":
+ return mock_lxd
+ raise RuntimeError("Unsupported on this platform")
+
+ monkeypatch.setattr(provider_service, "_get_provider_by_name", mock_get_by_name)
+
+ provider_service.prune_instances(all_providers=True)
+
+ mock_lxd.prune.assert_called_once_with(prune_templates=False)