Skip to content

Add dotnet tool exec command for one-shot tool execution #49329

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 45 commits into from
Jun 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
6ec461c
Add command
edvilme Apr 11, 2025
a36b14b
Add logic for acquisition and running
edvilme Apr 14, 2025
2f27844
Execute tool
edvilme Apr 14, 2025
9cc3c78
Load package if installed
edvilme Apr 14, 2025
1322829
tool-run: --from-source
edvilme Apr 15, 2025
2b2e18c
Save tool to temp directory
edvilme Apr 15, 2025
7706c72
Update translations
edvilme Apr 15, 2025
b9a03d2
Update cli snapshots
edvilme Apr 15, 2025
da3e28e
Address pr comments
edvilme Apr 16, 2025
3349a73
Fix typo
edvilme Apr 16, 2025
dd13c53
Fix typo
edvilme Apr 16, 2025
0ea6696
Move logic to ToolRunFromSourceCommand.cs
edvilme Apr 17, 2025
f689647
Add options
edvilme Apr 17, 2025
69cf295
Fix typo
edvilme Apr 17, 2025
623d36a
Run tool
edvilme Apr 17, 2025
1f6fc19
Simplify code and translations
edvilme Apr 18, 2025
aebf421
Update completions
edvilme Apr 18, 2025
dda7732
Add --interactive option
edvilme Apr 18, 2025
fdc0161
Update Completion snapshots
edvilme Apr 21, 2025
1beb4bb
tool-exec
edvilme Apr 21, 2025
37f00f9
Update Completions
edvilme Apr 21, 2025
5ccd294
Update cli snapshots
edvilme Apr 22, 2025
e07a46b
Add version option
edvilme Apr 22, 2025
eae9ad8
Pretty divider line
edvilme Apr 22, 2025
9fd3045
Address pr feedback
edvilme Apr 24, 2025
fd1cc46
Remove prompt for normal and quiter verbosities
edvilme Apr 28, 2025
d15b933
Store in nuget cache?
edvilme Apr 29, 2025
48c5699
Update based on PR feedback.
marcpopMSFT Jun 6, 2025
729fe49
Update strings
dsplaisted Jun 9, 2025
4c9b747
Make package identity argument work better with nullability
dsplaisted Jun 9, 2025
6fab085
Factor out some tool restore functionality into separate class
dsplaisted Jun 9, 2025
790d2cf
Simplify ToolRestoreResult
dsplaisted Jun 9, 2025
6f3e9a8
Look for local tools first with dotnet tool exec
dsplaisted Jun 9, 2025
a4ee7d1
Add method to NuGetPackageDownloader to get source with best package …
dsplaisted Jun 10, 2025
fac59a8
Updates to interface to support tool exec
dsplaisted Jun 10, 2025
321fd59
Prompt for tool exec only if tool hasn't been downloaded before
dsplaisted Jun 10, 2025
56cc63a
Improve dotnet tool exec UI
dsplaisted Jun 10, 2025
68f0638
Apply code review feedback
dsplaisted Jun 11, 2025
cde3627
Fix exception handling in mock package downloader
dsplaisted Jun 11, 2025
2e33374
Improve confirmation prompt and other text updates
dsplaisted Jun 11, 2025
cb66e95
Address TODOs
dsplaisted Jun 11, 2025
4bbafef
Update CLI snapshots
dsplaisted Jun 11, 2025
1d61173
Update --yes option description
dsplaisted Jun 11, 2025
ccde6b0
Apply code review feedback
dsplaisted Jun 11, 2025
b080028
Update CLI snapshots
dsplaisted Jun 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/Cli/dotnet/CliStrings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@
<value>More than one command is defined for the tool.</value>
</data>
<data name="ToolSettingsUnsupportedRunner" xml:space="preserve">
<value>Command '{0}' uses unsupported runner '{1}'."</value>
<value>Tool '{0}' uses unsupported runner '{1}'."</value>
</data>
<data name="ToolUnsupportedRuntimeIdentifier" xml:space="preserve">
<value>The tool does not support the current architecture or operating system ({0}). Supported runtimes: {1}</value>
Expand Down Expand Up @@ -812,4 +812,7 @@ For a list of locations searched, specify the "-d" option before the tool name.<
<value>Cannot specify --version when the package argument already contains a version.</value>
<comment>{Locked="--version"}</comment>
</data>
<data name="YesOptionDescription" xml:space="preserve">
<value>Accept all confirmation prompts using "yes."</value>
</data>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
</data>
<comment>{Locked="yes"}</comment>
</data>

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't actually think this needs to be locked, I've updated the confirmation prompt to support localization better.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't accept imply yes (whereas decline implies no)?

</root>
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

#nullable disable

using Microsoft.DotNet.Cli.Commands.Tool;
using Microsoft.DotNet.Cli.ToolManifest;
using Microsoft.DotNet.Cli.ToolPackage;
using Microsoft.DotNet.Cli.Utils;
Expand Down Expand Up @@ -91,31 +92,8 @@ private CommandSpec GetPackageCommandSpecUsingMuxer(CommandResolverArguments arg
toolCommandName.ToString()));
}

if (toolCommand.Runner == "dotnet")
{
if (toolManifestPackage.RollForward || allowRollForward)
{
arguments.CommandArguments = ["--allow-roll-forward", .. arguments.CommandArguments];
}

return MuxerCommandSpecMaker.CreatePackageCommandSpecUsingMuxer(
toolCommand.Executable.Value,
arguments.CommandArguments);
}
else if (toolCommand.Runner == "executable")
{
var escapedArgs = ArgumentEscaper.EscapeAndConcatenateArgArrayForProcessStart(
arguments.CommandArguments);

return new CommandSpec(
toolCommand.Executable.Value,
escapedArgs);
}
else
{
throw new GracefulException(string.Format(CliStrings.ToolSettingsUnsupportedRunner,
toolCommand.Name, toolCommand.Runner));
}
return ToolCommandSpecCreator.CreateToolCommandSpec(toolCommand.Name.Value, toolCommand.Executable.Value, toolCommand.Runner,
toolManifestPackage.RollForward || allowRollForward, arguments.CommandArguments);
}
else
{
Expand Down
28 changes: 28 additions & 0 deletions src/Cli/dotnet/Commands/CliCommandStrings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -2052,6 +2052,9 @@ If you would like to create a manifest, use the `--create-manifest-if-needed` fl
<data name="ToolRunCommandDescription" xml:space="preserve">
<value>Run a local tool. Note that this command cannot be used to run a global tool. </value>
</data>
<data name="ToolRunArgumentsDescription" xml:space="preserve">
<value>Arguments forwarded to the tool</value>
</data>
<data name="ToolSearchCommandDescription" xml:space="preserve">
<value>Search dotnet tools in nuget.org</value>
</data>
Expand Down Expand Up @@ -2476,4 +2479,29 @@ To display a value, specify the corresponding command-line option without provid
<data name="SolutionAddReferencedProjectsOptionDescription" xml:space="preserve">
<value>Recursively add projects' ReferencedProjects to solution</value>
</data>
<data name="ToolExecuteCommandDescription" xml:space="preserve">
<value>Executes a tool from source without permanently installing it.</value>
</data>
<data name="ToolDownloadConfirmationPrompt" xml:space="preserve">
<value>Tool package {0}@{1} will be downloaded from source {2}.
Proceed?</value>
</data>
<data name="ConfirmationPromptYesValue" xml:space="preserve">
<value>y</value>
<comment>For a command line connfirmation prompt, this is the key that should be pressed for "yes", ie to agree.</comment>
</data>
<data name="ConfirmationPromptNoValue" xml:space="preserve">
<value>n</value>
<comment>For a command line connfirmation prompt, this is the key that should be pressed for "no", ie to cancel the operation.</comment>
</data>
<data name="ConfirmationPromptInvalidChoiceMessage" xml:space="preserve">
<value>Please type '{0}' for yes or '{1}' for no.</value>
</data>
<data name="ToolDownloadCanceled" xml:space="preserve">
<value>Tool package download canceled</value>
</data>
<data name="ToolDownloadNeedsConfirmation" xml:space="preserve">
<value>Tool package download needs confirmation. Run in interactive mode or use the "--yes" command-line option to confirm.</value>
<comment>{Locked="--yes"}</comment>
</data>
</root>
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ namespace Microsoft.DotNet.Cli.Commands.Package.Add;

internal static class PackageAddCommandParser
{
public static readonly Argument<PackageIdentity> CmdPackageArgument = CommonArguments.PackageIdentityArgument(true)
public static readonly Argument<PackageIdentity> CmdPackageArgument = CommonArguments.RequiredPackageIdentityArgument()
.AddCompletions((context) =>
{
// we should take --prerelease flags into account for version completion
Expand Down
181 changes: 181 additions & 0 deletions src/Cli/dotnet/Commands/Tool/Execute/ToolExecuteCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.CommandLine;
using Microsoft.DotNet.Cli.CommandFactory;
using Microsoft.DotNet.Cli.CommandFactory.CommandResolution;
using Microsoft.DotNet.Cli.Commands.Tool.Install;
using Microsoft.DotNet.Cli.Commands.Tool.Restore;
using Microsoft.DotNet.Cli.Commands.Tool.Run;
using Microsoft.DotNet.Cli.Extensions;
using Microsoft.DotNet.Cli.NuGetPackageDownloader;
using Microsoft.DotNet.Cli.ToolManifest;
using Microsoft.DotNet.Cli.ToolPackage;
using Microsoft.DotNet.Cli.Utils;
using Microsoft.DotNet.Cli.Utils.Extensions;

using Microsoft.Extensions.EnvironmentAbstractions;
using NuGet.Common;
using NuGet.Configuration;
using NuGet.Packaging.Core;
using NuGet.Versioning;


namespace Microsoft.DotNet.Cli.Commands.Tool.Execute;

internal class ToolExecuteCommand(ParseResult result, ToolManifestFinder? toolManifestFinder = null, string? currentWorkingDirectory = null) : CommandBase(result)
{
const int ERROR_CANCELLED = 1223; // Windows error code for "Operation canceled by user"

private readonly PackageIdentity _packageToolIdentityArgument = result.GetRequiredValue(ToolExecuteCommandParser.PackageIdentityArgument);
private readonly IEnumerable<string> _forwardArguments = result.GetValue(ToolExecuteCommandParser.CommandArgument) ?? Enumerable.Empty<string>();
private readonly bool _allowRollForward = result.GetValue(ToolExecuteCommandParser.RollForwardOption);
private readonly string? _configFile = result.GetValue(ToolExecuteCommandParser.ConfigOption);
private readonly string[] _sources = result.GetValue(ToolExecuteCommandParser.SourceOption) ?? [];
private readonly string[] _addSource = result.GetValue(ToolExecuteCommandParser.AddSourceOption) ?? [];
private readonly bool _interactive = result.GetValue(ToolExecuteCommandParser.InteractiveOption);
private readonly VerbosityOptions _verbosity = result.GetValue(ToolExecuteCommandParser.VerbosityOption);
private readonly bool _yes = result.GetValue(ToolExecuteCommandParser.YesOption);
private readonly IToolPackageDownloader _toolPackageDownloader = ToolPackageFactory.CreateToolPackageStoresAndDownloader().downloader;

private readonly RestoreActionConfig _restoreActionConfig = new RestoreActionConfig(DisableParallel: result.GetValue(ToolCommandRestorePassThroughOptions.DisableParallelOption),
NoCache: result.GetValue(ToolCommandRestorePassThroughOptions.NoCacheOption) || result.GetValue(ToolCommandRestorePassThroughOptions.NoHttpCacheOption),
IgnoreFailedSources: result.GetValue(ToolCommandRestorePassThroughOptions.IgnoreFailedSourcesOption),
Interactive: result.GetValue(ToolExecuteCommandParser.InteractiveOption));

private readonly ToolManifestFinder _toolManifestFinder = toolManifestFinder ?? new ToolManifestFinder(new DirectoryPath(currentWorkingDirectory ?? Directory.GetCurrentDirectory()));

public override int Execute()
{
VersionRange versionRange = _parseResult.GetVersionRange();
PackageId packageId = new PackageId(_packageToolIdentityArgument.Id);

// Look in local tools manifest first, but only if version is not specified
if (versionRange == null)
{
var localToolsResolverCache = new LocalToolsResolverCache();

if (_toolManifestFinder.TryFindPackageId(packageId, out var toolManifestPackage))
{
var toolPackageRestorer = new ToolPackageRestorer(
_toolPackageDownloader,
_sources,
overrideSources: [],
_verbosity,
_restoreActionConfig,
localToolsResolverCache,
new FileSystemWrapper());

var restoreResult = toolPackageRestorer.InstallPackage(toolManifestPackage, _configFile == null ? null : new FilePath(_configFile));

if (!restoreResult.IsSuccess)
{
Reporter.Error.WriteLine(restoreResult.Message.Red());
return 1;
}

var localToolsCommandResolver = new LocalToolsCommandResolver(
_toolManifestFinder,
localToolsResolverCache);

return ToolRunCommand.ExecuteCommand(localToolsCommandResolver, toolManifestPackage.CommandNames.Single().Value, _forwardArguments, _allowRollForward);
}
}

var packageLocation = new PackageLocation(
nugetConfig: _configFile != null ? new(_configFile) : null,
sourceFeedOverrides: _sources,
additionalFeeds: _addSource);

(var bestVersion, var packageSource) = _toolPackageDownloader.GetNuGetVersion(packageLocation, packageId, _verbosity, versionRange, _restoreActionConfig);

IToolPackage toolPackage;

// TargetFramework is null, which means to use the current framework. Global tools can override the target framework to use (or select assets for),
// but we don't support this for local or one-shot tools.
if (!_toolPackageDownloader.TryGetDownloadedTool(packageId, bestVersion, targetFramework: null, out toolPackage))
{
if (!UserAgreedToRunFromSource(packageId, bestVersion, packageSource))
{
if (_interactive)
{
Reporter.Error.WriteLine(CliCommandStrings.ToolDownloadCanceled.Red().Bold());
return ERROR_CANCELLED;
}
else
{
Reporter.Error.WriteLine(CliCommandStrings.ToolDownloadNeedsConfirmation.Red().Bold());
return 1;
}
}

// We've already determined which source we will use and displayed that in a confirmation message to the user.
// So set the package location here to override the source feeds to just the source we already resolved to.
// This does mean that we won't work with feeds that have a primary package but where the RID-specific packages are on
// other feeds, but this is probably OK.
var downloadPackageLocation = new PackageLocation(
nugetConfig: _configFile != null ? new(_configFile) : null,
sourceFeedOverrides: [packageSource.Source],
additionalFeeds: _addSource);

toolPackage = _toolPackageDownloader.InstallPackage(
downloadPackageLocation,
packageId: packageId,
verbosity: _verbosity,
versionRange: new VersionRange(bestVersion, true, bestVersion, true),
isGlobalToolRollForward: false,
restoreActionConfig: _restoreActionConfig);
}

var commandSpec = ToolCommandSpecCreator.CreateToolCommandSpec(toolPackage.Command.Name.Value, toolPackage.Command.Executable.Value, toolPackage.Command.Runner, _allowRollForward, _forwardArguments);
var command = CommandFactoryUsingResolver.Create(commandSpec);
var result = command.Execute();
return result.ExitCode;
}

private bool UserAgreedToRunFromSource(PackageId packageId, NuGetVersion version, PackageSource source)
{
if (_yes)
{
return true;
}

if (!_interactive)
{
return false;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call to default to 'no' in this instance!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per @baronfel, npx does it the other way 😀. So we still might change it.

Copy link
Member

@nagilson nagilson Jun 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, that actually makes sense. No is better than no default, but I think we should change it too. It would make scripts more verbose. I don't see a scenario in CI where I'd write this but want to say no.

}

string promptMessage = string.Format(CliCommandStrings.ToolDownloadConfirmationPrompt, packageId, version.ToString(), source.Source);

static string AddPromptOptions(string message)
{
return $"{message} [{CliCommandStrings.ConfirmationPromptYesValue}/{CliCommandStrings.ConfirmationPromptNoValue}] ({CliCommandStrings.ConfirmationPromptYesValue}): ";
}

Console.Write(AddPromptOptions(promptMessage));

static bool KeyMatches(ConsoleKeyInfo pressedKey, string valueKey)
{
// Apparently you can't do invariant case insensitive comparison on a char directly, so we have to convert it to a string.
// The resource string should be a single character, but we take the first character just to be sure.
return pressedKey.KeyChar.ToString().ToLowerInvariant().Equals(
valueKey.ToLowerInvariant().Substring(0, 1));
}

while (true)
{
var key = Console.ReadKey();
Console.WriteLine();
if (key.Key == ConsoleKey.Enter || KeyMatches(key, CliCommandStrings.ConfirmationPromptYesValue))
{
return true;
}
if (key.Key == ConsoleKey.Escape || KeyMatches(key, CliCommandStrings.ConfirmationPromptNoValue))
{
return false;
}

Console.Write(AddPromptOptions(string.Format(CliCommandStrings.ConfirmationPromptInvalidChoiceMessage, CliCommandStrings.ConfirmationPromptYesValue, CliCommandStrings.ConfirmationPromptNoValue)));
}
}
}
64 changes: 64 additions & 0 deletions src/Cli/dotnet/Commands/Tool/Execute/ToolExecuteCommandParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.CommandLine;
using Microsoft.DotNet.Cli.Commands.Tool.Install;
using NuGet.Packaging.Core;

namespace Microsoft.DotNet.Cli.Commands.Tool.Execute;

internal static class ToolExecuteCommandParser

{
public static readonly Argument<PackageIdentity> PackageIdentityArgument = ToolInstallCommandParser.PackageIdentityArgument;

public static readonly Argument<IEnumerable<string>> CommandArgument = new("commandArguments")
{
Description = CliCommandStrings.ToolRunArgumentsDescription
};

public static readonly Option<string> VersionOption = ToolInstallCommandParser.VersionOption;
public static readonly Option<bool> RollForwardOption = ToolInstallCommandParser.RollForwardOption;
public static readonly Option<bool> PrereleaseOption = ToolInstallCommandParser.PrereleaseOption;
public static readonly Option<string> ConfigOption = ToolInstallCommandParser.ConfigOption;
public static readonly Option<string[]> SourceOption = ToolInstallCommandParser.SourceOption;
public static readonly Option<string[]> AddSourceOption = ToolInstallCommandParser.AddSourceOption;
public static readonly Option<bool> InteractiveOption = CommonOptions.InteractiveOption();
public static readonly Option<bool> YesOption = CommonOptions.YesOption;
public static readonly Option<VerbosityOptions> VerbosityOption = ToolInstallCommandParser.VerbosityOption;


public static readonly Command Command = ConstructCommand();
public static Command GetCommand()
{
return Command;
}

private static Command ConstructCommand()
{
Command command = new("execute", CliCommandStrings.ToolExecuteCommandDescription);

command.Aliases.Add("exec");

command.Arguments.Add(PackageIdentityArgument);
command.Arguments.Add(CommandArgument);

command.Options.Add(VersionOption);
command.Options.Add(YesOption);
command.Options.Add(InteractiveOption);
command.Options.Add(RollForwardOption);
command.Options.Add(PrereleaseOption);
command.Options.Add(ConfigOption);
command.Options.Add(SourceOption);
command.Options.Add(AddSourceOption);
command.Options.Add(ToolCommandRestorePassThroughOptions.DisableParallelOption);
command.Options.Add(ToolCommandRestorePassThroughOptions.IgnoreFailedSourcesOption);
command.Options.Add(ToolCommandRestorePassThroughOptions.NoCacheOption);
command.Options.Add(ToolCommandRestorePassThroughOptions.NoHttpCacheOption);
command.Options.Add(VerbosityOption);

command.SetAction((parseResult) => new ToolExecuteCommand(parseResult).Execute());

return command;
}
}
13 changes: 11 additions & 2 deletions src/Cli/dotnet/Commands/Tool/Install/ParseResultExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,17 @@ internal static class ParseResultExtension
{
public static VersionRange GetVersionRange(this ParseResult parseResult)
{
string packageVersion = parseResult.GetValue(ToolInstallCommandParser.PackageIdentityArgument)?.Version?.ToString() ??
parseResult.GetValue(ToolInstallCommandParser.VersionOption);
var packageVersionFromIdentityArgument = parseResult.GetValue(ToolInstallCommandParser.PackageIdentityArgument)?.Version?.ToString();
var packageVersionFromVersionOption = parseResult.GetValue(ToolInstallCommandParser.VersionOption);

// Check that only one of these has a value
if (!string.IsNullOrEmpty(packageVersionFromIdentityArgument) && !string.IsNullOrEmpty(packageVersionFromVersionOption))
{
throw new GracefulException(CliStrings.PackageIdentityArgumentVersionOptionConflict);
}

string packageVersion = packageVersionFromIdentityArgument ?? packageVersionFromVersionOption;

bool prerelease = parseResult.GetValue(ToolInstallCommandParser.PrereleaseOption);

if (!string.IsNullOrEmpty(packageVersion) && prerelease)
Expand Down
11 changes: 0 additions & 11 deletions src/Cli/dotnet/Commands/Tool/Install/ToolInstallCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,6 @@ internal class ToolInstallCommand(
private readonly string _framework = parseResult.GetValue(ToolInstallCommandParser.FrameworkOption);


internal static void EnsureNoConflictPackageIdentityVersionOption(ParseResult parseResult)
{
if (!string.IsNullOrEmpty(parseResult.GetValue(ToolInstallCommandParser.PackageIdentityArgument)?.Version?.ToString()) &&
!string.IsNullOrEmpty(parseResult.GetValue(ToolInstallCommandParser.VersionOption)))
{
throw new GracefulException(CliStrings.PackageIdentityArgumentVersionOptionConflict);
}
}

public override int Execute()
{
ToolAppliedOption.EnsureNoConflictGlobalLocalToolPathOption(
Expand All @@ -39,8 +30,6 @@ public override int Execute()
ToolAppliedOption.EnsureToolManifestAndOnlyLocalFlagCombination(
_parseResult);

EnsureNoConflictPackageIdentityVersionOption(_parseResult);

if (_global || !string.IsNullOrWhiteSpace(_toolPath))
{
return (_toolInstallGlobalOrToolPathCommand ?? new ToolInstallGlobalOrToolPathCommand(_parseResult)).Execute();
Expand Down
Loading
Loading