diff --git a/schemas/JSON/settings/settings.schema.0.2.json b/schemas/JSON/settings/settings.schema.0.2.json
index 25b04e3d1f..529ae05828 100644
--- a/schemas/JSON/settings/settings.schema.0.2.json
+++ b/schemas/JSON/settings/settings.schema.0.2.json
@@ -340,6 +340,36 @@
"default": false
}
}
+ },
+ "Output": {
+ "description": "Output display settings",
+ "type": "object",
+ "properties": {
+ "sortOrder": {
+ "description": "Fields to sort output by, in priority order. Use an empty array to disable sorting.",
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": [
+ "relevance",
+ "name",
+ "id",
+ "version",
+ "source",
+ "available"
+ ]
+ }
+ },
+ "sortDirection": {
+ "description": "Sort direction for output ordering",
+ "type": "string",
+ "enum": [
+ "ascending",
+ "descending"
+ ],
+ "default": "ascending"
+ }
+ }
}
},
"allOf": [
@@ -409,6 +439,12 @@
},
"additionalItems": true
},
+ {
+ "properties": {
+ "output": { "$ref": "#/definitions/Output" }
+ },
+ "additionalItems": true
+ },
{
"properties": {
"experimentalFeatures": { "$ref": "#/definitions/Experimental" }
diff --git a/src/AppInstallerCLICore/Argument.cpp b/src/AppInstallerCLICore/Argument.cpp
index 32bc8651c7..99699bdb0a 100644
--- a/src/AppInstallerCLICore/Argument.cpp
+++ b/src/AppInstallerCLICore/Argument.cpp
@@ -189,6 +189,12 @@ namespace AppInstaller::CLI
return { type, "upgrade-available"_liv};
case Execution::Args::Type::ListDetails:
return { type, "details"_liv };
+ case Execution::Args::Type::Sort:
+ return { type, "sort"_liv };
+ case Execution::Args::Type::SortAscending:
+ return { type, "ascending"_liv, "asc"_liv, ArgTypeCategory::None, ArgTypeExclusiveSet::SortDirection };
+ case Execution::Args::Type::SortDescending:
+ return { type, "descending"_liv, "desc"_liv, ArgTypeCategory::None, ArgTypeExclusiveSet::SortDirection };
// Pin command
case Execution::Args::Type::GatedVersion:
diff --git a/src/AppInstallerCLICore/Argument.h b/src/AppInstallerCLICore/Argument.h
index e7b438f1a7..c1a573db6a 100644
--- a/src/AppInstallerCLICore/Argument.h
+++ b/src/AppInstallerCLICore/Argument.h
@@ -92,6 +92,7 @@ namespace AppInstaller::CLI
ConfigurationSetChoice = 0x80,
DscResourceFunction = 0x100,
DependenciesConflict = 0x200,
+ SortDirection = 0x400,
// This must always be at the end
Max
diff --git a/src/AppInstallerCLICore/Commands/ListCommand.cpp b/src/AppInstallerCLICore/Commands/ListCommand.cpp
index 334ddb56c9..acb12bdd46 100644
--- a/src/AppInstallerCLICore/Commands/ListCommand.cpp
+++ b/src/AppInstallerCLICore/Commands/ListCommand.cpp
@@ -32,6 +32,9 @@ namespace AppInstaller::CLI
Argument{ Execution::Args::Type::IncludeUnknown, Resource::String::IncludeUnknownInListArgumentDescription, ArgumentType::Flag },
Argument{ Execution::Args::Type::IncludePinned, Resource::String::IncludePinnedInListArgumentDescription, ArgumentType::Flag},
Argument::ForType(Execution::Args::Type::ListDetails),
+ Argument{ Execution::Args::Type::Sort, Resource::String::SortArgumentDescription, ArgumentType::Standard },
+ Argument{ Execution::Args::Type::SortAscending, Resource::String::SortAscendingArgumentDescription, ArgumentType::Flag },
+ Argument{ Execution::Args::Type::SortDescending, Resource::String::SortDescendingArgumentDescription, ArgumentType::Flag },
};
}
diff --git a/src/AppInstallerCLICore/ExecutionArgs.h b/src/AppInstallerCLICore/ExecutionArgs.h
index fa7e9c6358..675576a9d7 100644
--- a/src/AppInstallerCLICore/ExecutionArgs.h
+++ b/src/AppInstallerCLICore/ExecutionArgs.h
@@ -114,6 +114,9 @@ namespace AppInstaller::CLI::Execution
// List Command
Upgrade, // Used in List command to only show versions with upgrades
ListDetails,
+ Sort, // Sort output by field (repeatable: --sort name --sort id)
+ SortAscending, // Sort output in ascending order
+ SortDescending, // Sort output in descending order
// Pin command
GatedVersion, // Differs from Version in that this supports wildcards
diff --git a/src/AppInstallerCLICore/Resources.h b/src/AppInstallerCLICore/Resources.h
index 11b4b8d1a7..fa369b8ae5 100644
--- a/src/AppInstallerCLICore/Resources.h
+++ b/src/AppInstallerCLICore/Resources.h
@@ -685,6 +685,9 @@ namespace AppInstaller::CLI::Resource
WINGET_DEFINE_RESOURCE_STRINGID(ShowVersion);
WINGET_DEFINE_RESOURCE_STRINGID(SilentArgumentDescription);
WINGET_DEFINE_RESOURCE_STRINGID(SingleCharAfterDashError);
+ WINGET_DEFINE_RESOURCE_STRINGID(SortArgumentDescription);
+ WINGET_DEFINE_RESOURCE_STRINGID(SortAscendingArgumentDescription);
+ WINGET_DEFINE_RESOURCE_STRINGID(SortDescendingArgumentDescription);
WINGET_DEFINE_RESOURCE_STRINGID(SkipDependenciesArgumentDescription);
WINGET_DEFINE_RESOURCE_STRINGID(DependenciesOnlyArgumentDescription);
WINGET_DEFINE_RESOURCE_STRINGID(DependenciesOnlyMessage);
diff --git a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw
index 7293ac52a6..3e51c8589b 100644
--- a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw
+++ b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw
@@ -524,6 +524,18 @@ They can be configured through the settings file 'winget settings'.
Only the single character alias can occur after a single -: '{0}'
{Locked="{0}"} Error message displayed when the user provides more than a single character command line alias argument after an alias argument specifier '-'. {0} is a placeholder replaced by the user's argument input.
+
+ Sort results by a specified field (can be repeated for multi-field sort)
+ Description for the --sort argument used to sort output by field name. Valid values are: name, id, version, source, available.
+
+
+ Sort results in ascending order
+ Description for the --ascending (--asc) flag that sets ascending sort direction for output.
+
+
+ Sort results in descending order
+ Description for the --descending (--desc) flag that sets descending sort direction for output.
+
A source with the given name already exists and refers to a different location:
diff --git a/src/AppInstallerCLITests/UserSettings.cpp b/src/AppInstallerCLITests/UserSettings.cpp
index 3d0820f8d5..dfe2a4ba54 100644
--- a/src/AppInstallerCLITests/UserSettings.cpp
+++ b/src/AppInstallerCLITests/UserSettings.cpp
@@ -660,3 +660,243 @@ TEST_CASE("LoggingChannels", "[settings]")
REQUIRE(userSettingTest.Get() == (Channel::CLI | Channel::SQL));
}
}
+
+TEST_CASE("SettingOutputSortOrder", "[settings]")
+{
+ auto again = DeleteUserSettingsFiles();
+
+ SECTION("Default value - empty vector sentinel for context-aware defaults")
+ {
+ UserSettingsTest userSettingTest;
+
+ auto sortOrder = userSettingTest.Get();
+ REQUIRE(sortOrder.empty());
+ REQUIRE(userSettingTest.GetWarnings().size() == 0);
+ }
+ SECTION("Single field")
+ {
+ std::string_view json = R"({ "output": { "sortOrder": ["id"] } })";
+ SetSetting(Stream::PrimaryUserSettings, json);
+ UserSettingsTest userSettingTest;
+
+ auto sortOrder = userSettingTest.Get();
+ REQUIRE(sortOrder.size() == 1);
+ REQUIRE(sortOrder[0] == SortField::Id);
+ REQUIRE(userSettingTest.GetWarnings().size() == 0);
+ }
+ SECTION("Relevance field")
+ {
+ std::string_view json = R"({ "output": { "sortOrder": ["relevance"] } })";
+ SetSetting(Stream::PrimaryUserSettings, json);
+ UserSettingsTest userSettingTest;
+
+ auto sortOrder = userSettingTest.Get();
+ REQUIRE(sortOrder.size() == 1);
+ REQUIRE(sortOrder[0] == SortField::Relevance);
+ REQUIRE(userSettingTest.GetWarnings().size() == 0);
+ }
+ SECTION("Multiple fields")
+ {
+ std::string_view json = R"({ "output": { "sortOrder": ["available", "name"] } })";
+ SetSetting(Stream::PrimaryUserSettings, json);
+ UserSettingsTest userSettingTest;
+
+ auto sortOrder = userSettingTest.Get();
+ REQUIRE(sortOrder.size() == 2);
+ REQUIRE(sortOrder[0] == SortField::Available);
+ REQUIRE(sortOrder[1] == SortField::Name);
+ REQUIRE(userSettingTest.GetWarnings().size() == 0);
+ }
+ SECTION("All valid fields")
+ {
+ std::string_view json = R"({ "output": { "sortOrder": ["relevance", "name", "id", "version", "source", "available"] } })";
+ SetSetting(Stream::PrimaryUserSettings, json);
+ UserSettingsTest userSettingTest;
+
+ auto sortOrder = userSettingTest.Get();
+ REQUIRE(sortOrder.size() == 6);
+ REQUIRE(sortOrder[0] == SortField::Relevance);
+ REQUIRE(sortOrder[1] == SortField::Name);
+ REQUIRE(sortOrder[2] == SortField::Id);
+ REQUIRE(sortOrder[3] == SortField::Version);
+ REQUIRE(sortOrder[4] == SortField::Source);
+ REQUIRE(sortOrder[5] == SortField::Available);
+ REQUIRE(userSettingTest.GetWarnings().size() == 0);
+ }
+ SECTION("Case insensitive")
+ {
+ std::string_view json = R"({ "output": { "sortOrder": ["Name", "ID", "VERSION"] } })";
+ SetSetting(Stream::PrimaryUserSettings, json);
+ UserSettingsTest userSettingTest;
+
+ auto sortOrder = userSettingTest.Get();
+ REQUIRE(sortOrder.size() == 3);
+ REQUIRE(sortOrder[0] == SortField::Name);
+ REQUIRE(sortOrder[1] == SortField::Id);
+ REQUIRE(sortOrder[2] == SortField::Version);
+ REQUIRE(userSettingTest.GetWarnings().size() == 0);
+ }
+ SECTION("Empty array disables sorting")
+ {
+ std::string_view json = R"({ "output": { "sortOrder": [] } })";
+ SetSetting(Stream::PrimaryUserSettings, json);
+ UserSettingsTest userSettingTest;
+
+ auto sortOrder = userSettingTest.Get();
+ REQUIRE(sortOrder.size() == 0);
+ REQUIRE(userSettingTest.GetWarnings().size() == 0);
+ }
+ SECTION("Invalid field name")
+ {
+ std::string_view json = R"({ "output": { "sortOrder": ["invalid"] } })";
+ SetSetting(Stream::PrimaryUserSettings, json);
+ UserSettingsTest userSettingTest;
+
+ auto sortOrder = userSettingTest.Get();
+ REQUIRE(sortOrder.empty());
+ REQUIRE(userSettingTest.GetWarnings().size() == 1);
+ }
+ SECTION("Mixed valid and invalid field")
+ {
+ std::string_view json = R"({ "output": { "sortOrder": ["name", "bogus"] } })";
+ SetSetting(Stream::PrimaryUserSettings, json);
+ UserSettingsTest userSettingTest;
+
+ auto sortOrder = userSettingTest.Get();
+ REQUIRE(sortOrder.empty());
+ REQUIRE(userSettingTest.GetWarnings().size() == 1);
+ }
+ SECTION("Duplicate values - case-insensitive")
+ {
+ std::string_view json = R"({ "output": { "sortOrder": ["name", "id", "Name"] } })";
+ SetSetting(Stream::PrimaryUserSettings, json);
+ UserSettingsTest userSettingTest;
+
+ auto sortOrder = userSettingTest.Get();
+ REQUIRE(sortOrder.size() == 3);
+ REQUIRE(sortOrder[0] == SortField::Name);
+ REQUIRE(sortOrder[1] == SortField::Id);
+ REQUIRE(sortOrder[2] == SortField::Name);
+ REQUIRE(userSettingTest.GetWarnings().empty());
+ }
+ SECTION("Wrong type - string instead of array")
+ {
+ std::string_view json = R"({ "output": { "sortOrder": "name" } })";
+ SetSetting(Stream::PrimaryUserSettings, json);
+ UserSettingsTest userSettingTest;
+
+ auto sortOrder = userSettingTest.Get();
+ REQUIRE(sortOrder.empty());
+ REQUIRE(userSettingTest.GetWarnings().size() == 1);
+ }
+ SECTION("Wrong type - number in array")
+ {
+ std::string_view json = R"({ "output": { "sortOrder": [1] } })";
+ SetSetting(Stream::PrimaryUserSettings, json);
+ UserSettingsTest userSettingTest;
+
+ auto sortOrder = userSettingTest.Get();
+ REQUIRE(sortOrder.empty());
+ REQUIRE(userSettingTest.GetWarnings().size() == 1);
+ }
+}
+
+TEST_CASE("SettingOutputSortDirection", "[settings]")
+{
+ auto again = DeleteUserSettingsFiles();
+
+ SECTION("Default value")
+ {
+ UserSettingsTest userSettingTest;
+
+ REQUIRE(userSettingTest.Get() == SortDirection::Ascending);
+ REQUIRE(userSettingTest.GetWarnings().size() == 0);
+ }
+ SECTION("Ascending")
+ {
+ std::string_view json = R"({ "output": { "sortDirection": "ascending" } })";
+ SetSetting(Stream::PrimaryUserSettings, json);
+ UserSettingsTest userSettingTest;
+
+ REQUIRE(userSettingTest.Get() == SortDirection::Ascending);
+ REQUIRE(userSettingTest.GetWarnings().size() == 0);
+ }
+ SECTION("Descending")
+ {
+ std::string_view json = R"({ "output": { "sortDirection": "descending" } })";
+ SetSetting(Stream::PrimaryUserSettings, json);
+ UserSettingsTest userSettingTest;
+
+ REQUIRE(userSettingTest.Get() == SortDirection::Descending);
+ REQUIRE(userSettingTest.GetWarnings().size() == 0);
+ }
+ SECTION("Case insensitive")
+ {
+ std::string_view json = R"({ "output": { "sortDirection": "DESCENDING" } })";
+ SetSetting(Stream::PrimaryUserSettings, json);
+ UserSettingsTest userSettingTest;
+
+ REQUIRE(userSettingTest.Get() == SortDirection::Descending);
+ REQUIRE(userSettingTest.GetWarnings().size() == 0);
+ }
+ SECTION("Invalid value")
+ {
+ std::string_view json = R"({ "output": { "sortDirection": "sideways" } })";
+ SetSetting(Stream::PrimaryUserSettings, json);
+ UserSettingsTest userSettingTest;
+
+ REQUIRE(userSettingTest.Get() == SortDirection::Ascending);
+ REQUIRE(userSettingTest.GetWarnings().size() == 1);
+ }
+ SECTION("Wrong type - array instead of string")
+ {
+ std::string_view json = R"({ "output": { "sortDirection": ["ascending"] } })";
+ SetSetting(Stream::PrimaryUserSettings, json);
+ UserSettingsTest userSettingTest;
+
+ REQUIRE(userSettingTest.Get() == SortDirection::Ascending);
+ REQUIRE(userSettingTest.GetWarnings().size() == 1);
+ }
+}
+
+TEST_CASE("ConvertToSortField", "[settings]")
+{
+ SECTION("Valid values - lowercase")
+ {
+ REQUIRE(ConvertToSortField("relevance") == SortField::Relevance);
+ REQUIRE(ConvertToSortField("name") == SortField::Name);
+ REQUIRE(ConvertToSortField("id") == SortField::Id);
+ REQUIRE(ConvertToSortField("version") == SortField::Version);
+ REQUIRE(ConvertToSortField("source") == SortField::Source);
+ REQUIRE(ConvertToSortField("available") == SortField::Available);
+ }
+ SECTION("Case-insensitive")
+ {
+ REQUIRE(ConvertToSortField("RELEVANCE") == SortField::Relevance);
+ REQUIRE(ConvertToSortField("NAME") == SortField::Name);
+ REQUIRE(ConvertToSortField("Id") == SortField::Id);
+ REQUIRE(ConvertToSortField("VERSION") == SortField::Version);
+ REQUIRE(ConvertToSortField("Source") == SortField::Source);
+ REQUIRE(ConvertToSortField("AVAILABLE") == SortField::Available);
+ }
+ SECTION("Invalid values return nullopt")
+ {
+ REQUIRE_FALSE(ConvertToSortField("").has_value());
+ REQUIRE_FALSE(ConvertToSortField("foo").has_value());
+ REQUIRE_FALSE(ConvertToSortField("names").has_value());
+ REQUIRE_FALSE(ConvertToSortField("nam").has_value());
+ }
+ SECTION("Settings round-trip with ConvertToSortField")
+ {
+ auto again = DeleteUserSettingsFiles();
+
+ std::string_view json = R"({ "output": { "sortOrder": ["name", "id"] } })";
+ SetSetting(Stream::PrimaryUserSettings, json);
+ UserSettingsTest userSettingTest;
+
+ auto fields = userSettingTest.Get();
+ REQUIRE(fields.size() == 2);
+ REQUIRE(fields[0] == SortField::Name);
+ REQUIRE(fields[1] == SortField::Id);
+ }
+}
diff --git a/src/AppInstallerCommonCore/Public/winget/UserSettings.h b/src/AppInstallerCommonCore/Public/winget/UserSettings.h
index 95fbe73549..3fbf1f2952 100644
--- a/src/AppInstallerCommonCore/Public/winget/UserSettings.h
+++ b/src/AppInstallerCommonCore/Public/winget/UserSettings.h
@@ -47,6 +47,27 @@ namespace AppInstaller::Settings
Disabled,
};
+ // Sort field for output ordering.
+ enum class SortField
+ {
+ Relevance, // Preserves current natural order (source-defined relevance ranking)
+ Name,
+ Id,
+ Version,
+ Source,
+ Available,
+ };
+
+ // Sort direction for output ordering.
+ enum class SortDirection
+ {
+ Ascending,
+ Descending,
+ };
+
+ // Converts a string to SortField. Returns std::nullopt for unrecognized values.
+ std::optional ConvertToSortField(std::string_view value);
+
// The download code to use for *installers*.
enum class InstallerDownloader
{
@@ -114,6 +135,9 @@ namespace AppInstaller::Settings
ConfigureDefaultModuleRoot,
// Interactivity
InteractivityDisable,
+ // Output behavior
+ OutputSortOrder,
+ OutputSortDirection,
#ifndef AICLI_DISABLE_TEST_HOOKS
// Debug
EnableSelfInitiatedMinidump,
@@ -208,6 +232,9 @@ namespace AppInstaller::Settings
SETTINGMAPPING_SPECIALIZATION(Setting::LoggingFileCountLimit, uint32_t, uint32_t, 0, ".logging.file.countLimit"sv);
// Interactivity
SETTINGMAPPING_SPECIALIZATION(Setting::InteractivityDisable, bool, bool, false, ".interactivity.disable"sv);
+ // Output behavior
+ SETTINGMAPPING_SPECIALIZATION(Setting::OutputSortOrder, std::vector, std::vector, std::vector{}, ".output.sortOrder"sv);
+ SETTINGMAPPING_SPECIALIZATION(Setting::OutputSortDirection, std::string, SortDirection, SortDirection::Ascending, ".output.sortDirection"sv);
// Used to deduce the SettingVariant type; making a variant that includes std::monostate and all SettingMapping types.
template
diff --git a/src/AppInstallerCommonCore/UserSettings.cpp b/src/AppInstallerCommonCore/UserSettings.cpp
index db276351ff..f15883be34 100644
--- a/src/AppInstallerCommonCore/UserSettings.cpp
+++ b/src/AppInstallerCommonCore/UserSettings.cpp
@@ -215,6 +215,26 @@ namespace AppInstaller::Settings
}
}
+ std::optional ConvertToSortField(std::string_view value)
+ {
+ static constexpr std::string_view s_sortField_relevance = "relevance";
+ static constexpr std::string_view s_sortField_name = "name";
+ static constexpr std::string_view s_sortField_id = "id";
+ static constexpr std::string_view s_sortField_version = "version";
+ static constexpr std::string_view s_sortField_source = "source";
+ static constexpr std::string_view s_sortField_available = "available";
+
+ std::string lowered = Utility::ToLower(value);
+
+ if (lowered == s_sortField_relevance) return SortField::Relevance;
+ if (lowered == s_sortField_name) return SortField::Name;
+ if (lowered == s_sortField_id) return SortField::Id;
+ if (lowered == s_sortField_version) return SortField::Version;
+ if (lowered == s_sortField_source) return SortField::Source;
+ if (lowered == s_sortField_available) return SortField::Available;
+ return std::nullopt;
+ }
+
namespace details
{
#define WINGET_VALIDATE_SIGNATURE(_setting_) \
@@ -482,6 +502,69 @@ namespace AppInstaller::Settings
{
return value * 24h;
}
+
+ WINGET_VALIDATE_SIGNATURE(OutputSortOrder)
+ {
+ static constexpr std::string_view s_sortField_relevance = "relevance";
+ static constexpr std::string_view s_sortField_name = "name";
+ static constexpr std::string_view s_sortField_id = "id";
+ static constexpr std::string_view s_sortField_version = "version";
+ static constexpr std::string_view s_sortField_source = "source";
+ static constexpr std::string_view s_sortField_available = "available";
+
+ std::vector fields;
+ for (auto const& entry : value)
+ {
+ std::string lowered = Utility::ToLower(entry);
+
+ if (lowered == s_sortField_relevance)
+ {
+ fields.emplace_back(SortField::Relevance);
+ }
+ else if (lowered == s_sortField_name)
+ {
+ fields.emplace_back(SortField::Name);
+ }
+ else if (lowered == s_sortField_id)
+ {
+ fields.emplace_back(SortField::Id);
+ }
+ else if (lowered == s_sortField_version)
+ {
+ fields.emplace_back(SortField::Version);
+ }
+ else if (lowered == s_sortField_source)
+ {
+ fields.emplace_back(SortField::Source);
+ }
+ else if (lowered == s_sortField_available)
+ {
+ fields.emplace_back(SortField::Available);
+ }
+ else
+ {
+ return {};
+ }
+ }
+ return fields;
+ }
+
+ WINGET_VALIDATE_SIGNATURE(OutputSortDirection)
+ {
+ static constexpr std::string_view s_sortDirection_ascending = "ascending";
+ static constexpr std::string_view s_sortDirection_descending = "descending";
+
+ if (Utility::CaseInsensitiveEquals(value, s_sortDirection_ascending))
+ {
+ return SortDirection::Ascending;
+ }
+ else if (Utility::CaseInsensitiveEquals(value, s_sortDirection_descending))
+ {
+ return SortDirection::Descending;
+ }
+
+ return {};
+ }
}
#ifndef AICLI_DISABLE_TEST_HOOKS