diff --git a/cli/Directory.Build.props b/cli/Directory.Build.props
new file mode 100644
index 000000000..c9c5763c6
--- /dev/null
+++ b/cli/Directory.Build.props
@@ -0,0 +1,9 @@
+
+
+
+ Duende.$(MSBuildProjectName)
+ Duende.$(MSBuildProjectName)
+ Duende.$(MSBuildProjectName)
+
+
+
diff --git a/cli/Directory.Build.targets b/cli/Directory.Build.targets
new file mode 100644
index 000000000..60390bb19
--- /dev/null
+++ b/cli/Directory.Build.targets
@@ -0,0 +1,3 @@
+
+
+
diff --git a/cli/cli-abstractions.slnf b/cli/cli-abstractions.slnf
new file mode 100644
index 000000000..d469d1323
--- /dev/null
+++ b/cli/cli-abstractions.slnf
@@ -0,0 +1,8 @@
+{
+ "solution": {
+ "path": "..\\products.slnx",
+ "projects": [
+ "cli\\src\\Cli.PluginAbstractions\\Cli.PluginAbstractions.csproj"
+ ]
+ }
+}
diff --git a/cli/cli.slnf b/cli/cli.slnf
new file mode 100644
index 000000000..11fbd1205
--- /dev/null
+++ b/cli/cli.slnf
@@ -0,0 +1,12 @@
+{
+ "solution": {
+ "path": "..\\products.slnx",
+ "projects": [
+ "cli\\src\\Cli.PluginAbstractions\\Cli.PluginAbstractions.csproj",
+ "cli\\src\\Cli\\Cli.csproj",
+ "cli\\testing\\Cli.TestPlugin\\Cli.TestPlugin.csproj",
+ "cli\\test\\Cli.IntegrationTests\\Cli.IntegrationTests.csproj",
+ "cli\\test\\Cli.Tests\\Cli.Tests.csproj"
+ ]
+ }
+}
diff --git a/cli/src/Cli.PluginAbstractions/Cli.PluginAbstractions.csproj b/cli/src/Cli.PluginAbstractions/Cli.PluginAbstractions.csproj
new file mode 100644
index 000000000..433421fba
--- /dev/null
+++ b/cli/src/Cli.PluginAbstractions/Cli.PluginAbstractions.csproj
@@ -0,0 +1,13 @@
+
+
+
+ cli-abs-
+ 0.1
+
+
+
+
+
+
+
+
diff --git a/cli/src/Cli.PluginAbstractions/CliPluginAttribute.cs b/cli/src/Cli.PluginAbstractions/CliPluginAttribute.cs
new file mode 100644
index 000000000..35b845761
--- /dev/null
+++ b/cli/src/Cli.PluginAbstractions/CliPluginAttribute.cs
@@ -0,0 +1,45 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+namespace Duende.Cli.PluginAbstractions;
+
+///
+/// Assembly-level attribute that identifies the type implementing .
+/// The CLI host scans loaded plugin assemblies for this attribute to discover plugin entry points.
+///
+///
+///
+/// [assembly: CliPlugin(typeof(StorageCliPlugin))]
+///
+///
+[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false, Inherited = false)]
+public sealed class CliPluginAttribute : Attribute
+{
+ ///
+ /// Gets the type that implements .
+ ///
+ public Type PluginType { get; }
+
+ ///
+ /// Initializes a new instance of .
+ ///
+ /// The type implementing .
+ public CliPluginAttribute(Type pluginType)
+ {
+ ArgumentNullException.ThrowIfNull(pluginType);
+
+ if (!typeof(ICliPlugin).IsAssignableFrom(pluginType))
+ {
+ throw new ArgumentException(
+ $"Type '{pluginType.FullName}' does not implement {nameof(ICliPlugin)}.", nameof(pluginType));
+ }
+
+ if (pluginType.IsAbstract)
+ {
+ throw new ArgumentException(
+ $"Type '{pluginType.FullName}' is abstract and cannot be used as a plugin.", nameof(pluginType));
+ }
+
+ PluginType = pluginType;
+ }
+}
diff --git a/cli/src/Cli.PluginAbstractions/ICliPlugin.cs b/cli/src/Cli.PluginAbstractions/ICliPlugin.cs
new file mode 100644
index 000000000..5c3cfadc3
--- /dev/null
+++ b/cli/src/Cli.PluginAbstractions/ICliPlugin.cs
@@ -0,0 +1,23 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.CommandLine;
+
+namespace Duende.Cli.PluginAbstractions;
+
+///
+/// Defines the contract for a CLI plugin that contributes commands to the duende tool.
+///
+public interface ICliPlugin
+{
+ ///
+ /// Gets the name of the plugin (e.g. "storage"). Used as the subcommand name.
+ ///
+ string Name { get; }
+
+ ///
+ /// Gets the that this plugin contributes to the CLI root command.
+ /// The returned command and all its subcommands are mounted directly onto the root.
+ ///
+ Command GetCommand();
+}
diff --git a/cli/src/Cli.PluginAbstractions/IPluginContext.cs b/cli/src/Cli.PluginAbstractions/IPluginContext.cs
new file mode 100644
index 000000000..0541a3b6b
--- /dev/null
+++ b/cli/src/Cli.PluginAbstractions/IPluginContext.cs
@@ -0,0 +1,27 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Microsoft.Extensions.Logging;
+
+namespace Duende.Cli.PluginAbstractions;
+
+///
+/// Provides host services to CLI plugins at runtime.
+///
+public interface IPluginContext
+{
+ ///
+ /// Gets a logger for the plugin to use.
+ ///
+ ILogger Logger { get; }
+
+ ///
+ /// Gets the version of the duende CLI tool host.
+ ///
+ string HostVersion { get; }
+
+ ///
+ /// Gets a cancellation token that is cancelled when the CLI is shutting down.
+ ///
+ CancellationToken CancellationToken { get; }
+}
diff --git a/cli/src/Cli/Cli.csproj b/cli/src/Cli/Cli.csproj
new file mode 100644
index 000000000..ec79da0bd
--- /dev/null
+++ b/cli/src/Cli/Cli.csproj
@@ -0,0 +1,27 @@
+
+
+
+ Exe
+ true
+ duende
+ Major
+
+ $(NoWarn);CA1303
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cli/src/Cli/Commands/PluginCacheHandler.cs b/cli/src/Cli/Commands/PluginCacheHandler.cs
new file mode 100644
index 000000000..78a46e4dd
--- /dev/null
+++ b/cli/src/Cli/Commands/PluginCacheHandler.cs
@@ -0,0 +1,51 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Duende.Cli.Plugins;
+
+using NuGet.Configuration;
+
+namespace Duende.Cli.Commands;
+
+///
+/// Handles the duende plugin cache clear command.
+/// Removes cached Duende CLI plugin packages from the NuGet global packages folder.
+///
+internal static class PluginCacheHandler
+{
+ internal static void Execute()
+ {
+ var globalPackagesFolder = SettingsUtility.GetGlobalPackagesFolder(Settings.LoadDefaultSettings(null));
+ var cleared = 0;
+
+ foreach (var (_, info) in PluginRegistry.KnownPlugins)
+ {
+#pragma warning disable CA1308 // NuGet global packages folder uses lowercase package IDs by convention
+ var packageDir = Path.Combine(globalPackagesFolder, info.PackageId.ToLowerInvariant());
+#pragma warning restore CA1308
+
+ if (Directory.Exists(packageDir))
+ {
+ try
+ {
+ Directory.Delete(packageDir, recursive: true);
+ Console.WriteLine($"Cleared cache for {info.PackageId}");
+ cleared++;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ Console.Error.WriteLine($"Warning: could not clear cache for {info.PackageId}: {ex.Message}");
+ }
+ }
+ }
+
+ if (cleared == 0)
+ {
+ Console.WriteLine("No cached plugins found.");
+ }
+ else
+ {
+ Console.WriteLine($"Cleared {cleared} cached plugin(s).");
+ }
+ }
+}
diff --git a/cli/src/Cli/Commands/PluginListHandler.cs b/cli/src/Cli/Commands/PluginListHandler.cs
new file mode 100644
index 000000000..932e64e54
--- /dev/null
+++ b/cli/src/Cli/Commands/PluginListHandler.cs
@@ -0,0 +1,33 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Duende.Cli.Plugins;
+
+namespace Duende.Cli.Commands;
+
+///
+/// Handles the duende plugin list command.
+/// Lists all known plugins and their resolved versions from project context.
+///
+internal static class PluginListHandler
+{
+ internal static void Execute()
+ {
+ Console.WriteLine("Known plugins:");
+ Console.WriteLine();
+ Console.WriteLine($" {"Name",-15} {"Package",-35} {"Version",-15} {"Status"}");
+ Console.WriteLine($" {"----",-15} {"-------",-35} {"-------",-15} {"------"}");
+
+ foreach (var (name, info) in PluginRegistry.KnownPlugins)
+ {
+ var resolution = ProjectContextScanner.Resolve(name);
+ var version = resolution?.Version ?? "(not detected)";
+ var status = resolution is not null ? "resolved" : "no project context";
+
+ Console.WriteLine($" {name,-15} {info.PackageId,-35} {version,-15} {status}");
+ }
+
+ Console.WriteLine();
+ Console.WriteLine("Tip: Run from a project directory to auto-detect plugin versions.");
+ }
+}
diff --git a/cli/src/Cli/Plugins/NuGetPluginResolver.cs b/cli/src/Cli/Plugins/NuGetPluginResolver.cs
new file mode 100644
index 000000000..8cd6b04e6
--- /dev/null
+++ b/cli/src/Cli/Plugins/NuGetPluginResolver.cs
@@ -0,0 +1,162 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using NuGet.Common;
+using NuGet.Configuration;
+using NuGet.Packaging;
+using NuGet.Protocol;
+using NuGet.Protocol.Core.Types;
+using NuGet.Versioning;
+
+namespace Duende.Cli.Plugins;
+
+///
+/// Resolves and downloads CLI plugin packages from nuget.org using the NuGet global packages folder as cache.
+///
+internal static class NuGetPluginResolver
+{
+ private static readonly string GlobalPackagesFolder =
+ SettingsUtility.GetGlobalPackagesFolder(Settings.LoadDefaultSettings(null));
+
+ ///
+ /// Ensures the plugin package is available in the NuGet global packages folder,
+ /// downloading it from nuget.org if not already cached.
+ ///
+ /// The resolved plugin package ID and version range (e.g. "7.2.*").
+ /// Cancellation token.
+ /// The path to the plugin's primary assembly.
+ internal static async Task EnsureDownloadedAsync(PluginResolution resolution, Ct ct)
+ {
+ // Check if any version matching the range is already cached
+ var cachedPath = FindCachedAssembly(resolution.PackageId, resolution.Version);
+ if (cachedPath is not null)
+ {
+ return cachedPath;
+ }
+
+ // Download from nuget.org
+ var repository = Repository.Factory.GetCoreV3("https://api.nuget.org/v3/index.json");
+ var resource = await repository.GetResourceAsync(ct);
+
+ using var cache = new SourceCacheContext();
+ var versions = await resource.GetAllVersionsAsync(resolution.PackageId, cache, NullLogger.Instance, ct);
+
+ if (!VersionRange.TryParse(resolution.Version, out var versionRange))
+ {
+ throw new InvalidOperationException(
+ $"'{resolution.Version}' is not a valid NuGet version range for '{resolution.PackageId}'.");
+ }
+
+ var bestMatch = versionRange.FindBestMatch(versions)
+ ?? throw new InvalidOperationException(
+ $"No version of '{resolution.PackageId}' matching '{resolution.Version}' was found on nuget.org.");
+
+ Console.WriteLine($"Downloading {resolution.PackageId} {bestMatch}...");
+
+ using var packageStream = new MemoryStream();
+ var downloaded = await resource.CopyNupkgToStreamAsync(
+ resolution.PackageId, bestMatch, packageStream, cache, NullLogger.Instance, ct);
+
+ if (!downloaded)
+ {
+ throw new InvalidOperationException(
+ $"Failed to download '{resolution.PackageId}' {bestMatch} from nuget.org.");
+ }
+
+ packageStream.Position = 0;
+
+ // Extract to global packages folder — NuGet convention requires lowercase package IDs in path
+#pragma warning disable CA1308 // NuGet global packages folder uses lowercase package IDs by convention
+ var packageDir = Path.Combine(GlobalPackagesFolder, resolution.PackageId.ToLowerInvariant(), bestMatch.ToString());
+#pragma warning restore CA1308
+
+ // Use a temp directory + atomic rename to avoid races between concurrent invocations
+ var tempDir = $"{packageDir}.{Path.GetRandomFileName()}";
+ _ = Directory.CreateDirectory(tempDir);
+
+ try
+ {
+ using var packageReader = new PackageArchiveReader(packageStream);
+ var tfmFolder = GetBestTfmFolder(packageReader);
+
+ foreach (var file in packageReader.GetFiles(tfmFolder))
+ {
+ var destPath = Path.Combine(tempDir, Path.GetFileName(file));
+ using var fileStream = packageReader.GetStream(file);
+ using var dest = File.Create(destPath);
+ await fileStream.CopyToAsync(dest, ct);
+ }
+
+ try
+ {
+ Directory.Move(tempDir, packageDir);
+ }
+ catch (IOException) when (Directory.Exists(packageDir))
+ {
+ // Another process won the race — use their copy
+ Directory.Delete(tempDir, recursive: true);
+ }
+ }
+ catch
+ {
+ // Clean up temp directory on any failure
+#pragma warning disable CA1031 // Best-effort cleanup — must not mask the original exception
+ try { Directory.Delete(tempDir, recursive: true); } catch { /* best effort */ }
+#pragma warning restore CA1031
+ throw;
+ }
+
+ return FindAssemblyInDirectory(packageDir, resolution.PackageId)
+ ?? throw new InvalidOperationException(
+ $"Could not find plugin assembly in downloaded package '{resolution.PackageId}'.");
+ }
+
+ private static string? FindCachedAssembly(string packageId, string versionRange)
+ {
+#pragma warning disable CA1308 // NuGet global packages folder uses lowercase package IDs by convention
+ var packageDir = Path.Combine(GlobalPackagesFolder, packageId.ToLowerInvariant());
+#pragma warning restore CA1308
+ if (!Directory.Exists(packageDir))
+ {
+ return null;
+ }
+
+ if (!VersionRange.TryParse(versionRange, out var range))
+ {
+ return null;
+ }
+
+ var installedVersions = Directory.GetDirectories(packageDir)
+ .Select(d => NuGetVersion.TryParse(Path.GetFileName(d), out var v) ? v : null)
+ .OfType();
+
+ var best = range.FindBestMatch(installedVersions);
+ if (best is null)
+ {
+ return null;
+ }
+
+ var versionDir = Path.Combine(packageDir, best.ToString());
+ return FindAssemblyInDirectory(versionDir, packageId);
+ }
+
+ private static string? FindAssemblyInDirectory(string directory, string packageId)
+ {
+ var assemblyName = $"{packageId}.dll";
+ var path = Path.Combine(directory, assemblyName);
+ return File.Exists(path) ? path : null;
+ }
+
+ private static string GetBestTfmFolder(PackageArchiveReader reader)
+ {
+ // Pick the highest TFM version available in the package
+ var libItems = reader.GetLibItems().ToList();
+ var preferred = libItems
+ .OrderByDescending(g => g.TargetFramework.Version)
+ .FirstOrDefault();
+
+ return preferred?.TargetFramework.GetShortFolderName() is { } tfm
+ ? $"lib/{tfm}"
+ : "lib";
+ }
+}
diff --git a/cli/src/Cli/Plugins/PluginAssemblyLoadContext.cs b/cli/src/Cli/Plugins/PluginAssemblyLoadContext.cs
new file mode 100644
index 000000000..a42da4d7e
--- /dev/null
+++ b/cli/src/Cli/Plugins/PluginAssemblyLoadContext.cs
@@ -0,0 +1,39 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Reflection;
+using System.Runtime.Loader;
+
+namespace Duende.Cli.Plugins;
+
+///
+/// Isolated for a single plugin.
+/// Loads plugin assemblies and their dependencies in isolation, while sharing
+/// Duende.Cli.PluginAbstractions and System.CommandLine from the default context.
+///
+internal sealed class PluginAssemblyLoadContext(string pluginAssemblyPath) : AssemblyLoadContext(
+ name: Path.GetFileNameWithoutExtension(pluginAssemblyPath),
+ isCollectible: true)
+{
+ private readonly AssemblyDependencyResolver _resolver = new(pluginAssemblyPath);
+
+ // Assemblies shared between host and plugin — must not be loaded twice
+ private static readonly HashSet SharedAssemblies =
+ [
+ "Duende.Cli.PluginAbstractions",
+ "Microsoft.Extensions.Logging.Abstractions",
+ "System.CommandLine",
+ ];
+
+ protected override Assembly? Load(AssemblyName assemblyName)
+ {
+ // Delegate shared assemblies to the default context
+ if (assemblyName.Name is not null && SharedAssemblies.Contains(assemblyName.Name))
+ {
+ return null; // null = fall through to default context
+ }
+
+ var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
+ return assemblyPath is not null ? LoadFromAssemblyPath(assemblyPath) : null;
+ }
+}
diff --git a/cli/src/Cli/Plugins/PluginLoader.cs b/cli/src/Cli/Plugins/PluginLoader.cs
new file mode 100644
index 000000000..9366e7142
--- /dev/null
+++ b/cli/src/Cli/Plugins/PluginLoader.cs
@@ -0,0 +1,54 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Runtime.Loader;
+using Duende.Cli.PluginAbstractions;
+
+namespace Duende.Cli.Plugins;
+
+///
+/// Loads a plugin assembly from a file path into an isolated
+/// and returns the implementation.
+///
+internal static class PluginLoader
+{
+ internal static ICliPlugin Load(string assemblyPath)
+ {
+ var context = new PluginAssemblyLoadContext(assemblyPath);
+ var assembly = context.LoadFromAssemblyPath(assemblyPath);
+
+ var attribute = assembly.GetCustomAttributes(typeof(CliPluginAttribute), false)
+ .OfType()
+ .FirstOrDefault()
+ ?? throw new InvalidOperationException(
+ $"Assembly '{assemblyPath}' does not have a [CliPlugin] attribute. " +
+ "Ensure the plugin assembly declares [assembly: CliPlugin(typeof(YourPlugin))].");
+
+ if (!typeof(ICliPlugin).IsAssignableFrom(attribute.PluginType))
+ {
+ throw new InvalidOperationException(
+ $"Plugin type '{attribute.PluginType.FullName}' does not implement {nameof(ICliPlugin)}. " +
+ $"Ensure the type specified in [CliPlugin(typeof(...))] implements {nameof(ICliPlugin)}.");
+ }
+
+ ICliPlugin instance;
+ try
+ {
+ instance = (ICliPlugin)(Activator.CreateInstance(attribute.PluginType)
+ ?? throw new InvalidOperationException(
+ $"Activator.CreateInstance returned null for '{attribute.PluginType.FullName}'."));
+ }
+ catch (InvalidOperationException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException(
+ $"Failed to create an instance of '{attribute.PluginType.FullName}'. " +
+ "Ensure the plugin type has a public parameterless constructor.", ex);
+ }
+
+ return instance;
+ }
+}
diff --git a/cli/src/Cli/Plugins/PluginOrchestrator.cs b/cli/src/Cli/Plugins/PluginOrchestrator.cs
new file mode 100644
index 000000000..28860a32c
--- /dev/null
+++ b/cli/src/Cli/Plugins/PluginOrchestrator.cs
@@ -0,0 +1,65 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.CommandLine;
+
+namespace Duende.Cli.Plugins;
+
+///
+/// Orchestrates lazy plugin loading: resolves the plugin version from project context,
+/// deployed assemblies, or explicit version flag, downloads via NuGet if not cached,
+/// loads via AssemblyLoadContext, and re-invokes parsing.
+///
+internal static class PluginOrchestrator
+{
+ internal static async Task LoadAndReInvokeAsync(
+ string pluginName,
+ string? pluginPath,
+ string? explicitVersion,
+ string[] originalArgs,
+ CancellationToken ct)
+ {
+ // Phase 1: resolve the plugin assembly path
+ string assemblyPath;
+
+ if (pluginPath is not null)
+ {
+ // Local dev: load directly from path
+ assemblyPath = Path.GetFullPath(pluginPath);
+ }
+ else
+ {
+ // Try project context + deployed assembly scanning
+ var resolution = ProjectContextScanner.Resolve(pluginName);
+
+ // Fallback to explicit --plugin-version
+ if (resolution is null && explicitVersion is not null)
+ {
+ resolution = ProjectContextScanner.ResolveExplicit(pluginName, explicitVersion);
+ }
+
+ if (resolution is null)
+ {
+ throw new InvalidOperationException(
+ $"Could not determine the version for plugin '{pluginName}'. " +
+ "No project context (Directory.Packages.props / .csproj) or deployed anchor assembly was found. " +
+ "Use --plugin-version to specify the plugin version, " +
+ "or --plugin-path to load a plugin directly.");
+ }
+
+ // Download from nuget.org if not already cached
+ assemblyPath = await NuGetPluginResolver.EnsureDownloadedAsync(resolution, ct);
+ }
+
+ // Phase 2: load plugin via ALC
+ var plugin = PluginLoader.Load(assemblyPath);
+
+ // Phase 3: build a fresh root with the real plugin command and re-invoke
+ var reInvokeRoot = new RootCommand();
+ reInvokeRoot.Subcommands.Add(plugin.GetCommand());
+ // InvokeAsync does not accept a CancellationToken in System.CommandLine 2.0.x
+#pragma warning disable CA2016 // CancellationToken not supported by this overload
+ return await reInvokeRoot.Parse(originalArgs).InvokeAsync();
+#pragma warning restore CA2016
+ }
+}
diff --git a/cli/src/Cli/Plugins/PluginPreloader.cs b/cli/src/Cli/Plugins/PluginPreloader.cs
new file mode 100644
index 000000000..676754140
--- /dev/null
+++ b/cli/src/Cli/Plugins/PluginPreloader.cs
@@ -0,0 +1,153 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.CommandLine;
+
+namespace Duende.Cli.Plugins;
+
+///
+/// Detects which plugin the user is invoking from raw args and eagerly loads it
+/// before the command tree is built. This ensures duende storage --help
+/// shows real subcommands from the plugin rather than the stub help.
+///
+internal static class PluginPreloader
+{
+ private const string PluginPathFlag = "--plugin-path";
+ private const string VersionFlag = "--plugin-version";
+
+ ///
+ /// Scans for a known plugin name as the first
+ /// non-option argument (e.g. "storage" in "duende storage --help").
+ /// Returns the plugin name or null if none detected.
+ ///
+ internal static string? DetectRequestedPlugin(string[] args)
+ {
+ var skipNext = false;
+ foreach (var arg in args)
+ {
+ if (skipNext)
+ {
+ skipNext = false;
+ continue;
+ }
+
+ // Skip option flags that take a value
+ if (arg is PluginPathFlag or VersionFlag)
+ {
+ skipNext = true;
+ continue;
+ }
+
+ if (arg.StartsWith('-'))
+ {
+ continue;
+ }
+
+ // First positional arg — check if it's a known plugin
+ if (PluginRegistry.KnownPlugins.ContainsKey(arg))
+ {
+ return arg;
+ }
+
+ // First positional arg that isn't a plugin (e.g. "version", "plugin")
+ return null;
+ }
+
+ return null;
+ }
+
+ ///
+ /// Extracts the value of --plugin-path directly from raw args.
+ ///
+ internal static string? DetectPluginPath(string[] args)
+ {
+ for (var i = 0; i < args.Length - 1; i++)
+ {
+ if (args[i] == PluginPathFlag)
+ {
+ return args[i + 1];
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Extracts the value of --plugin-version directly from raw args.
+ ///
+ internal static string? DetectVersion(string[] args)
+ {
+ for (var i = 0; i < args.Length - 1; i++)
+ {
+ if (args[i] == VersionFlag)
+ {
+ return args[i + 1];
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Eagerly loads the real for the
+ /// named plugin. Resolution chain: --plugin-path → project context
+ /// → deployed assembly → --plugin-version → null.
+ ///
+ internal static async Task LoadPluginCommandAsync(
+ string pluginName,
+ string? pluginPath,
+ string? explicitVersion,
+ CancellationToken ct)
+ {
+ try
+ {
+ string assemblyPath;
+
+ if (pluginPath is not null)
+ {
+ assemblyPath = Path.GetFullPath(pluginPath);
+ }
+ else
+ {
+ // Try project context + deployed assembly scanning
+ var resolution = ProjectContextScanner.Resolve(pluginName);
+
+ // Fallback to explicit --plugin-version
+ if (resolution is null && explicitVersion is not null)
+ {
+ resolution = ProjectContextScanner.ResolveExplicit(pluginName, explicitVersion);
+ }
+
+ if (resolution is null)
+ {
+ return null;
+ }
+
+ assemblyPath = await NuGetPluginResolver.EnsureDownloadedAsync(resolution, ct);
+ }
+
+ var plugin = PluginLoader.Load(assemblyPath);
+ return plugin.GetCommand();
+ }
+ catch (InvalidOperationException ex)
+ {
+ await Console.Error.WriteLineAsync($"Warning: could not pre-load plugin '{pluginName}': {ex.Message}");
+ return null;
+ }
+ catch (HttpRequestException ex)
+ {
+ await Console.Error.WriteLineAsync($"Warning: could not pre-load plugin '{pluginName}': {ex.Message}");
+ return null;
+ }
+ catch (TaskCanceledException ex)
+ {
+ await Console.Error.WriteLineAsync($"Warning: could not pre-load plugin '{pluginName}': {ex.Message}");
+ return null;
+ }
+ catch (IOException ex)
+ {
+ await Console.Error.WriteLineAsync($"Warning: could not pre-load plugin '{pluginName}': {ex.Message}");
+ return null;
+ }
+ }
+}
diff --git a/cli/src/Cli/Plugins/PluginRegistry.cs b/cli/src/Cli/Plugins/PluginRegistry.cs
new file mode 100644
index 000000000..f528c9cad
--- /dev/null
+++ b/cli/src/Cli/Plugins/PluginRegistry.cs
@@ -0,0 +1,30 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+namespace Duende.Cli.Plugins;
+
+///
+/// Information about a known CLI plugin.
+///
+internal sealed record PluginInfo(string PackageId, string AnchorPackage, string Description);
+
+///
+/// The resolved plugin package ID and version, ready for NuGet download.
+///
+internal sealed record PluginResolution(string PackageId, string Version);
+
+///
+/// Hardcoded registry of all known first-party Duende CLI plugins.
+/// Maps the subcommand name to plugin NuGet package information.
+///
+internal static class PluginRegistry
+{
+ internal static readonly IReadOnlyDictionary KnownPlugins =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["storage"] = new(
+ PackageId: "Duende.Storage.CliPlugin",
+ AnchorPackage: "Duende.Storage",
+ Description: "Duende Storage"),
+ };
+}
diff --git a/cli/src/Cli/Plugins/PluginStubRegistrar.cs b/cli/src/Cli/Plugins/PluginStubRegistrar.cs
new file mode 100644
index 000000000..5f4cadbd5
--- /dev/null
+++ b/cli/src/Cli/Plugins/PluginStubRegistrar.cs
@@ -0,0 +1,60 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.CommandLine;
+
+namespace Duende.Cli.Plugins;
+
+///
+/// Registers stub commands for known plugins onto the root command.
+/// Each stub command lazily downloads and loads the real plugin on first invocation.
+///
+internal static class PluginStubRegistrar
+{
+ /// Registers stubs for all known plugins.
+ internal static void Register(
+ RootCommand rootCommand,
+ Option pluginPathOption,
+ Option pluginVersionOption,
+ string[] originalArgs) =>
+ Register(rootCommand, pluginPathOption, pluginVersionOption, originalArgs, onlyPlugin: null, excludePlugin: null);
+
+ ///
+ /// Registers stubs for known plugins, filtered by
+ /// or . Pass onlyPlugin to register a
+ /// single stub; pass excludePlugin to register all except one.
+ ///
+ internal static void Register(
+ RootCommand rootCommand,
+ Option pluginPathOption,
+ Option pluginVersionOption,
+ string[] originalArgs,
+ string? onlyPlugin = null,
+ string? excludePlugin = null)
+ {
+ foreach (var (name, info) in PluginRegistry.KnownPlugins)
+ {
+ if (onlyPlugin is not null && !string.Equals(name, onlyPlugin, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ if (excludePlugin is not null && string.Equals(name, excludePlugin, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var stubCommand = new Command(name, $"Commands from the {info.Description} plugin. (Downloads on first use.)");
+ var pluginNameCapture = name;
+
+ stubCommand.SetAction(async (parseResult, ct) =>
+ {
+ var pluginPath = parseResult.GetValue(pluginPathOption);
+ var explicitVersion = parseResult.GetValue(pluginVersionOption);
+ Environment.ExitCode = await PluginOrchestrator.LoadAndReInvokeAsync(pluginNameCapture, pluginPath, explicitVersion, originalArgs, ct);
+ });
+
+ rootCommand.Subcommands.Add(stubCommand);
+ }
+ }
+}
diff --git a/cli/src/Cli/Plugins/ProjectContextScanner.cs b/cli/src/Cli/Plugins/ProjectContextScanner.cs
new file mode 100644
index 000000000..2fc308d32
--- /dev/null
+++ b/cli/src/Cli/Plugins/ProjectContextScanner.cs
@@ -0,0 +1,186 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Xml;
+using System.Xml.Linq;
+
+namespace Duende.Cli.Plugins;
+
+///
+/// Scans the current directory and its ancestors for NuGet package references
+/// to determine the version of a plugin's anchor package.
+/// Supports Central Package Management (Directory.Packages.props),
+/// direct .csproj references, and deployed assembly scanning.
+///
+internal static class ProjectContextScanner
+{
+ private const string DirectoryPackagesProps = "Directory.Packages.props";
+
+ ///
+ /// Resolves the plugin version using the full fallback chain:
+ /// project context → deployed assembly scanning.
+ ///
+ /// The plugin name (e.g. "storage").
+ /// The directory to start scanning from. Defaults to current directory.
+ /// A if the anchor package is found; otherwise null.
+ internal static PluginResolution? Resolve(string pluginName, string? searchDirectory = null)
+ {
+ if (!PluginRegistry.KnownPlugins.TryGetValue(pluginName, out var info))
+ {
+ return null;
+ }
+
+ var directory = searchDirectory ?? Directory.GetCurrentDirectory();
+
+ // Strategy 1: scan project files (Directory.Packages.props, .csproj)
+ var anchorVersion = FindAnchorVersionFromProjectFiles(info.AnchorPackage, directory);
+
+ // Strategy 2: scan for deployed anchor assembly
+ anchorVersion ??= FindAnchorVersionFromDeployedAssembly(info.AnchorPackage, directory);
+
+ if (anchorVersion is null)
+ {
+ return null;
+ }
+
+ var pluginVersion = ToMajorMinorWildcard(anchorVersion);
+ return new PluginResolution(info.PackageId, pluginVersion);
+ }
+
+ ///
+ /// Creates a from an explicit version string (e.g. from --plugin-version).
+ ///
+ internal static PluginResolution? ResolveExplicit(string pluginName, string version)
+ {
+ if (!PluginRegistry.KnownPlugins.TryGetValue(pluginName, out var info))
+ {
+ return null;
+ }
+
+ var pluginVersion = ToMajorMinorWildcard(version);
+ return new PluginResolution(info.PackageId, pluginVersion);
+ }
+
+ private static string? FindAnchorVersionFromProjectFiles(string anchorPackage, string startDirectory)
+ {
+ var directory = new DirectoryInfo(startDirectory);
+
+ while (directory is not null)
+ {
+ // Check Directory.Packages.props (Central Package Management)
+ var packagesProps = Path.Combine(directory.FullName, DirectoryPackagesProps);
+ if (File.Exists(packagesProps))
+ {
+ var version = ExtractVersionFromPackagesProps(packagesProps, anchorPackage);
+ if (version is not null)
+ {
+ return version;
+ }
+ }
+
+ // Check .csproj files in this directory
+ try
+ {
+ foreach (var csproj in directory.GetFiles("*.csproj"))
+ {
+ var version = ExtractVersionFromCsproj(csproj.FullName, anchorPackage);
+ if (version is not null)
+ {
+ return version;
+ }
+ }
+ }
+ catch (Exception ex) when (ex is UnauthorizedAccessException or DirectoryNotFoundException)
+ {
+ // Skip inaccessible directories
+ }
+
+ directory = directory.Parent;
+ }
+
+ return null;
+ }
+
+ ///
+ /// Scans the directory for a deployed anchor assembly (e.g. Duende.Storage.dll)
+ /// and reads its version information to determine the product version.
+ /// Uses to avoid loading the assembly.
+ ///
+ private static string? FindAnchorVersionFromDeployedAssembly(string anchorPackage, string directory)
+ {
+ var assemblyFileName = $"{anchorPackage}.dll";
+ var assemblyPath = Path.Combine(directory, assemblyFileName);
+
+ if (!File.Exists(assemblyPath))
+ {
+ return null;
+ }
+
+ try
+ {
+ // FileVersionInfo reads the PE version resource without loading the assembly
+ var fileInfo = System.Diagnostics.FileVersionInfo.GetVersionInfo(assemblyPath);
+
+ // ProductVersion corresponds to AssemblyInformationalVersionAttribute
+ if (fileInfo.ProductVersion is { Length: > 0 } productVersion)
+ {
+ // Strip build metadata (e.g. "7.2.1+abc123" → "7.2.1")
+ var plusIndex = productVersion.IndexOf('+', StringComparison.Ordinal);
+ return plusIndex >= 0 ? productVersion[..plusIndex] : productVersion;
+ }
+
+ // Fall back to FileVersion (corresponds to AssemblyFileVersionAttribute)
+ if (fileInfo.FileMajorPart > 0)
+ {
+ return $"{fileInfo.FileMajorPart}.{fileInfo.FileMinorPart}.{fileInfo.FileBuildPart}";
+ }
+
+ return null;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ return null;
+ }
+ }
+
+ private static string? ExtractVersionFromPackagesProps(string filePath, string packageId)
+ {
+ try
+ {
+ var doc = XDocument.Load(filePath);
+ return doc.Descendants("PackageVersion")
+ .FirstOrDefault(e =>
+ string.Equals(e.Attribute("Include")?.Value, packageId, StringComparison.OrdinalIgnoreCase))
+ ?.Attribute("Version")?.Value;
+ }
+ catch (Exception ex) when (ex is XmlException or IOException or UnauthorizedAccessException)
+ {
+ return null;
+ }
+ }
+
+ private static string? ExtractVersionFromCsproj(string filePath, string packageId)
+ {
+ try
+ {
+ var doc = XDocument.Load(filePath);
+ return doc.Descendants("PackageReference")
+ .FirstOrDefault(e =>
+ string.Equals(e.Attribute("Include")?.Value, packageId, StringComparison.OrdinalIgnoreCase))
+ ?.Attribute("Version")?.Value;
+ }
+ catch (Exception ex) when (ex is XmlException or IOException or UnauthorizedAccessException)
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Converts a version string like "7.2.1" to "7.2.*" for NuGet floating version resolution.
+ ///
+ internal static string ToMajorMinorWildcard(string version)
+ {
+ var parts = version.Split('.');
+ return parts.Length >= 2 ? $"{parts[0]}.{parts[1]}.*" : $"{version}.*";
+ }
+}
diff --git a/cli/src/Cli/Program.cs b/cli/src/Cli/Program.cs
new file mode 100644
index 000000000..a1daa1024
--- /dev/null
+++ b/cli/src/Cli/Program.cs
@@ -0,0 +1,83 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.CommandLine;
+using System.Reflection;
+using Duende.Cli.Commands;
+using Duende.Cli.Plugins;
+
+var rootCommand = new RootCommand("Duende CLI — tooling for Duende Software products.");
+
+// Built-in: version
+var versionCommand = new Command("version", "Show the Duende CLI version.");
+versionCommand.SetAction(_ =>
+{
+ var version = Assembly.GetExecutingAssembly()
+ .GetCustomAttribute()
+ ?.InformationalVersion ?? "unknown";
+ Console.WriteLine($"duende {version}");
+});
+rootCommand.Subcommands.Add(versionCommand);
+
+// Built-in: plugin
+var pluginCommand = new Command("plugin", "Manage CLI plugins.");
+rootCommand.Subcommands.Add(pluginCommand);
+
+// plugin list
+var pluginListCommand = new Command("list", "List known plugins and their resolved versions.");
+pluginListCommand.SetAction(_ => PluginListHandler.Execute());
+pluginCommand.Subcommands.Add(pluginListCommand);
+
+// plugin cache
+var pluginCacheCommand = new Command("cache", "Manage the plugin cache.");
+pluginCommand.Subcommands.Add(pluginCacheCommand);
+
+var pluginCacheClearCommand = new Command("clear", "Clear the NuGet global packages cache entries for Duende plugins.");
+pluginCacheClearCommand.SetAction(_ => PluginCacheHandler.Execute());
+pluginCacheCommand.Subcommands.Add(pluginCacheClearCommand);
+
+// --plugin-path option (for local development — bypasses NuGet resolution)
+var pluginPathOption = new Option("--plugin-path")
+{
+ Description = "Load a plugin directly from a DLL path, bypassing NuGet resolution.",
+};
+rootCommand.Options.Add(pluginPathOption);
+
+// --plugin-version option (explicit plugin version when no project context or deployed assembly is available)
+var pluginVersionOption = new Option("--plugin-version")
+{
+ Description = "Specify the plugin version to download (e.g. 7.2.1). Used when no project context or deployed assembly is found.",
+};
+rootCommand.Options.Add(pluginVersionOption);
+
+// Detect which plugin (if any) the user is invoking so we can register it before parsing.
+// This ensures `duende storage --help` shows the real subcommands, not just the stub.
+var requestedPlugin = PluginPreloader.DetectRequestedPlugin(args);
+var pluginPath = PluginPreloader.DetectPluginPath(args);
+var explicitVersion = PluginPreloader.DetectVersion(args);
+
+if (requestedPlugin is not null)
+{
+ // Eagerly load the real plugin command and register it in place of the stub
+ var realCommand = await PluginPreloader.LoadPluginCommandAsync(
+ requestedPlugin, pluginPath, explicitVersion, CancellationToken.None);
+ if (realCommand is not null)
+ {
+ rootCommand.Subcommands.Add(realCommand);
+ }
+ else
+ {
+ // Fallback to stub if loading fails (e.g. no project context, no --plugin-path, no --plugin-version)
+ PluginStubRegistrar.Register(rootCommand, pluginPathOption, pluginVersionOption, args, onlyPlugin: requestedPlugin);
+ }
+
+ // Register stubs for all other plugins
+ PluginStubRegistrar.Register(rootCommand, pluginPathOption, pluginVersionOption, args, excludePlugin: requestedPlugin);
+}
+else
+{
+ // No specific plugin invoked — register stubs for all known plugins
+ PluginStubRegistrar.Register(rootCommand, pluginPathOption, pluginVersionOption, args);
+}
+
+return await rootCommand.Parse(args).InvokeAsync();
diff --git a/cli/src/Directory.Build.props b/cli/src/Directory.Build.props
new file mode 100644
index 000000000..d004ddc20
--- /dev/null
+++ b/cli/src/Directory.Build.props
@@ -0,0 +1,10 @@
+
+
+
+
+ 0.1
+ cli-
+ Duende CLI
+
+
+
diff --git a/cli/src/Directory.Build.targets b/cli/src/Directory.Build.targets
new file mode 100644
index 000000000..acbac1881
--- /dev/null
+++ b/cli/src/Directory.Build.targets
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/cli/test/Cli.IntegrationTests/Cli.IntegrationTests.csproj b/cli/test/Cli.IntegrationTests/Cli.IntegrationTests.csproj
new file mode 100644
index 000000000..0f0f66395
--- /dev/null
+++ b/cli/test/Cli.IntegrationTests/Cli.IntegrationTests.csproj
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/cli/test/Cli.IntegrationTests/PluginLoaderTests.cs b/cli/test/Cli.IntegrationTests/PluginLoaderTests.cs
new file mode 100644
index 000000000..4494f795c
--- /dev/null
+++ b/cli/test/Cli.IntegrationTests/PluginLoaderTests.cs
@@ -0,0 +1,51 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.Runtime.Loader;
+using Duende.Cli.Plugins;
+
+namespace Duende.Cli.IntegrationTests;
+
+public class PluginLoaderTests
+{
+ private static readonly string AssemblyPath =
+ typeof(Duende.Cli.TestPlugin.TestCliPlugin).Assembly.Location;
+
+ [Fact]
+ public void PluginLoadsViaPluginLoader()
+ {
+ var plugin = PluginLoader.Load(AssemblyPath);
+
+ plugin.Name.ShouldBe("test");
+ }
+
+ [Fact]
+ public void PluginReturnsCommandWithCorrectName()
+ {
+ var plugin = PluginLoader.Load(AssemblyPath);
+
+ var command = plugin.GetCommand();
+
+ command.Name.ShouldBe("test");
+ }
+
+ [Fact]
+ public void PluginCommandHasSubcommand()
+ {
+ var plugin = PluginLoader.Load(AssemblyPath);
+
+ var command = plugin.GetCommand();
+
+ command.Subcommands.ShouldContain(c => c.Name == "hello");
+ }
+
+ [Fact]
+ public void PluginLoadsInIsolatedAlc()
+ {
+ var plugin = PluginLoader.Load(AssemblyPath);
+
+ var context = AssemblyLoadContext.GetLoadContext(plugin.GetType().Assembly);
+
+ context.ShouldNotBe(AssemblyLoadContext.Default);
+ }
+}
diff --git a/cli/test/Cli.Tests/Cli.Tests.csproj b/cli/test/Cli.Tests/Cli.Tests.csproj
new file mode 100644
index 000000000..2c164a755
--- /dev/null
+++ b/cli/test/Cli.Tests/Cli.Tests.csproj
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/cli/test/Cli.Tests/PluginPreloaderTests.cs b/cli/test/Cli.Tests/PluginPreloaderTests.cs
new file mode 100644
index 000000000..495997f9a
--- /dev/null
+++ b/cli/test/Cli.Tests/PluginPreloaderTests.cs
@@ -0,0 +1,90 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Duende.Cli.Plugins;
+
+namespace Duende.Cli.Tests;
+
+public class PluginPreloaderTests
+{
+ [Fact]
+ public void DetectRequestedPlugin_returns_known_plugin_name()
+ {
+ var result = PluginPreloader.DetectRequestedPlugin(["storage", "--help"]);
+
+ result.ShouldBe("storage");
+ }
+
+ [Fact]
+ public void DetectRequestedPlugin_skips_plugin_path_value()
+ {
+ var result = PluginPreloader.DetectRequestedPlugin(["--plugin-path", "foo.dll", "storage"]);
+
+ result.ShouldBe("storage");
+ }
+
+ [Fact]
+ public void DetectRequestedPlugin_returns_null_for_non_plugin_command()
+ {
+ var result = PluginPreloader.DetectRequestedPlugin(["version"]);
+
+ result.ShouldBeNull();
+ }
+
+ [Fact]
+ public void DetectRequestedPlugin_returns_null_for_option_flag()
+ {
+ var result = PluginPreloader.DetectRequestedPlugin(["--help"]);
+
+ result.ShouldBeNull();
+ }
+
+ [Fact]
+ public void DetectRequestedPlugin_returns_null_for_empty_args()
+ {
+ var result = PluginPreloader.DetectRequestedPlugin([]);
+
+ result.ShouldBeNull();
+ }
+
+ [Fact]
+ public void DetectPluginPath_returns_path_when_present()
+ {
+ var result = PluginPreloader.DetectPluginPath(
+ ["--plugin-path", "/path/to/plugin.dll", "storage"]);
+
+ result.ShouldBe("/path/to/plugin.dll");
+ }
+
+ [Fact]
+ public void DetectPluginPath_returns_null_when_not_present()
+ {
+ var result = PluginPreloader.DetectPluginPath(["storage", "--help"]);
+
+ result.ShouldBeNull();
+ }
+
+ [Fact]
+ public void DetectVersion_returns_version_when_present()
+ {
+ var result = PluginPreloader.DetectVersion(["--plugin-version", "7.2.1", "storage"]);
+
+ result.ShouldBe("7.2.1");
+ }
+
+ [Fact]
+ public void DetectVersion_returns_null_when_not_present()
+ {
+ var result = PluginPreloader.DetectVersion(["storage", "--help"]);
+
+ result.ShouldBeNull();
+ }
+
+ [Fact]
+ public void DetectRequestedPlugin_skips_version_flag_value()
+ {
+ var result = PluginPreloader.DetectRequestedPlugin(["--plugin-version", "7.2.1", "storage"]);
+
+ result.ShouldBe("storage");
+ }
+}
diff --git a/cli/test/Cli.Tests/PluginRegistryTests.cs b/cli/test/Cli.Tests/PluginRegistryTests.cs
new file mode 100644
index 000000000..714f3192c
--- /dev/null
+++ b/cli/test/Cli.Tests/PluginRegistryTests.cs
@@ -0,0 +1,28 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Duende.Cli.Plugins;
+
+namespace Duende.Cli.Tests;
+
+public class PluginRegistryTests
+{
+ [Fact]
+ public void KnownPlugins_contains_storage_key() =>
+ PluginRegistry.KnownPlugins.ShouldContainKey("storage");
+
+ [Fact]
+ public void Storage_entry_has_correct_PackageId() =>
+ PluginRegistry.KnownPlugins["storage"].PackageId.ShouldBe("Duende.Storage.CliPlugin");
+
+ [Fact]
+ public void Storage_entry_has_correct_AnchorPackage() =>
+ PluginRegistry.KnownPlugins["storage"].AnchorPackage.ShouldBe("Duende.Storage");
+
+ [Theory]
+ [InlineData("Storage")]
+ [InlineData("STORAGE")]
+ [InlineData("sToRaGe")]
+ public void Lookup_is_case_insensitive(string key) =>
+ PluginRegistry.KnownPlugins.ShouldContainKey(key);
+}
diff --git a/cli/test/Cli.Tests/ProjectContextScannerTests.cs b/cli/test/Cli.Tests/ProjectContextScannerTests.cs
new file mode 100644
index 000000000..7b096c22c
--- /dev/null
+++ b/cli/test/Cli.Tests/ProjectContextScannerTests.cs
@@ -0,0 +1,149 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Duende.Cli.Plugins;
+
+namespace Duende.Cli.Tests;
+
+public class ProjectContextScannerTests : IDisposable
+{
+ private readonly string _tempDir;
+
+ public ProjectContextScannerTests()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), "CliTests_" + Guid.NewGuid().ToString("N"));
+ _ = Directory.CreateDirectory(_tempDir);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_tempDir))
+ {
+ Directory.Delete(_tempDir, recursive: true);
+ }
+
+ GC.SuppressFinalize(this);
+ }
+
+ [Fact]
+ public void Resolve_finds_anchor_version_from_Directory_Packages_props()
+ {
+ var propsContent = """
+
+
+
+
+
+ """;
+ File.WriteAllText(Path.Combine(_tempDir, "Directory.Packages.props"), propsContent);
+
+ var result = ProjectContextScanner.Resolve("storage", _tempDir);
+
+ var resolved = result.ShouldNotBeNull();
+ resolved.PackageId.ShouldBe("Duende.Storage.CliPlugin");
+ resolved.Version.ShouldBe("7.2.*");
+ }
+
+ [Fact]
+ public void Resolve_returns_null_when_no_anchor_package_exists()
+ {
+ var propsContent = """
+
+
+
+
+
+ """;
+ File.WriteAllText(Path.Combine(_tempDir, "Directory.Packages.props"), propsContent);
+
+ var result = ProjectContextScanner.Resolve("storage", _tempDir);
+
+ result.ShouldBeNull();
+ }
+
+ [Fact]
+ public void Resolve_returns_null_for_unknown_plugin_name()
+ {
+ var result = ProjectContextScanner.Resolve("nonexistent", _tempDir);
+
+ result.ShouldBeNull();
+ }
+
+ [Fact]
+ public void Resolve_finds_anchor_version_from_csproj()
+ {
+ var csprojContent = """
+
+
+
+
+
+ """;
+ File.WriteAllText(Path.Combine(_tempDir, "Test.csproj"), csprojContent);
+
+ var result = ProjectContextScanner.Resolve("storage", _tempDir);
+
+ var resolved = result.ShouldNotBeNull();
+ resolved.PackageId.ShouldBe("Duende.Storage.CliPlugin");
+ resolved.Version.ShouldBe("8.0.*");
+ }
+
+ [Fact]
+ public void Resolve_finds_version_from_deployed_assembly()
+ {
+ // Copy the Storage.CliPlugin assembly as a stand-in for "Duende.Storage.dll"
+ // We need a real .NET assembly with version info — use the test assembly itself
+ var sourceAssembly = typeof(ProjectContextScannerTests).Assembly.Location;
+ var anchorPath = Path.Combine(_tempDir, "Duende.Storage.dll");
+ File.Copy(sourceAssembly, anchorPath);
+
+ // The test assembly won't have the right version, but we can verify the scanner
+ // finds *something* from the deployed assembly (non-null result)
+ var result = ProjectContextScanner.Resolve("storage", _tempDir);
+
+ // Should resolve — the assembly has version info even if it's not "7.2.1"
+ var resolved = result.ShouldNotBeNull();
+ resolved.PackageId.ShouldBe("Duende.Storage.CliPlugin");
+ }
+
+ [Fact]
+ public void Resolve_prefers_project_context_over_deployed_assembly()
+ {
+ // Set up both project context and deployed assembly
+ var propsContent = """
+
+
+
+
+
+ """;
+ File.WriteAllText(Path.Combine(_tempDir, "Directory.Packages.props"), propsContent);
+
+ var sourceAssembly = typeof(ProjectContextScannerTests).Assembly.Location;
+ File.Copy(sourceAssembly, Path.Combine(_tempDir, "Duende.Storage.dll"));
+
+ var result = ProjectContextScanner.Resolve("storage", _tempDir);
+
+ // Should use project context version, not assembly version
+ var resolved = result.ShouldNotBeNull();
+ resolved.Version.ShouldBe("7.2.*");
+ }
+
+ [Fact]
+ public void ResolveExplicit_creates_resolution_from_version_string()
+ {
+ var result = ProjectContextScanner.ResolveExplicit("storage", "7.2.1");
+
+ var resolved = result.ShouldNotBeNull();
+ resolved.PackageId.ShouldBe("Duende.Storage.CliPlugin");
+ resolved.Version.ShouldBe("7.2.*");
+ }
+
+ [Fact]
+ public void ResolveExplicit_returns_null_for_unknown_plugin()
+ {
+ var result = ProjectContextScanner.ResolveExplicit("nonexistent", "1.0.0");
+
+ result.ShouldBeNull();
+ }
+}
diff --git a/cli/test/Directory.Build.props b/cli/test/Directory.Build.props
new file mode 100644
index 000000000..0047a9176
--- /dev/null
+++ b/cli/test/Directory.Build.props
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+ $(NoWarn);IDE1006
+
+
diff --git a/cli/testing/Cli.TestPlugin/AssemblyInfo.cs b/cli/testing/Cli.TestPlugin/AssemblyInfo.cs
new file mode 100644
index 000000000..fc308c265
--- /dev/null
+++ b/cli/testing/Cli.TestPlugin/AssemblyInfo.cs
@@ -0,0 +1,7 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using Duende.Cli.PluginAbstractions;
+using Duende.Cli.TestPlugin;
+
+[assembly: CliPlugin(typeof(TestCliPlugin))]
diff --git a/cli/testing/Cli.TestPlugin/Cli.TestPlugin.csproj b/cli/testing/Cli.TestPlugin/Cli.TestPlugin.csproj
new file mode 100644
index 000000000..431a03475
--- /dev/null
+++ b/cli/testing/Cli.TestPlugin/Cli.TestPlugin.csproj
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/cli/testing/Cli.TestPlugin/TestCliPlugin.cs b/cli/testing/Cli.TestPlugin/TestCliPlugin.cs
new file mode 100644
index 000000000..ef3c4c838
--- /dev/null
+++ b/cli/testing/Cli.TestPlugin/TestCliPlugin.cs
@@ -0,0 +1,24 @@
+// Copyright (c) Duende Software. All rights reserved.
+// See LICENSE in the project root for license information.
+
+using System.CommandLine;
+using Duende.Cli.PluginAbstractions;
+
+namespace Duende.Cli.TestPlugin;
+
+///
+/// A minimal CLI plugin used for integration testing the plugin loading infrastructure.
+///
+public sealed class TestCliPlugin : ICliPlugin
+{
+ ///
+ public string Name => "test";
+
+ ///
+ public Command GetCommand()
+ {
+ var command = new Command("test", "A test plugin for integration testing.");
+ command.Subcommands.Add(new Command("hello", "A sample subcommand."));
+ return command;
+ }
+}
diff --git a/products.slnx b/products.slnx
index 8b8803f2f..8f03cf59c 100644
--- a/products.slnx
+++ b/products.slnx
@@ -27,6 +27,9 @@
+
+
+