diff --git a/.pipelines/ESRPSigning_core.json b/.pipelines/ESRPSigning_core.json
index b417597184..72840bebbd 100644
--- a/.pipelines/ESRPSigning_core.json
+++ b/.pipelines/ESRPSigning_core.json
@@ -117,6 +117,7 @@
"WinUI3Apps\\PowerToys.FileLocksmithUI.dll",
"WinUI3Apps\\PowerToys.FileLocksmithContextMenu.dll",
"FileLocksmithContextMenuPackage.msix",
+ "FileLocksmithCLI.exe",
"WinUI3Apps\\Peek.Common.dll",
"WinUI3Apps\\Peek.FilePreviewer.dll",
diff --git a/PowerToys.slnx b/PowerToys.slnx
index 8bcadb8f1c..ac006fdbf4 100644
--- a/PowerToys.slnx
+++ b/PowerToys.slnx
@@ -420,6 +420,7 @@
+
@@ -429,6 +430,9 @@
+
+
+
diff --git a/src/modules/FileLocksmith/FileLocksmithCLI/CLILogic.cpp b/src/modules/FileLocksmith/FileLocksmithCLI/CLILogic.cpp
new file mode 100644
index 0000000000..17015e1ea3
--- /dev/null
+++ b/src/modules/FileLocksmith/FileLocksmithCLI/CLILogic.cpp
@@ -0,0 +1,248 @@
+#include "pch.h"
+#include "CLILogic.h"
+#include
+#include
+#include
+#include
+#include "resource.h"
+#include
+#include
+#include
+
+template
+DWORD_PTR ToDwordPtr(T val)
+{
+ if constexpr (std::is_pointer_v)
+ {
+ return reinterpret_cast(val);
+ }
+ else
+ {
+ return static_cast(val);
+ }
+}
+
+template
+std::wstring FormatString(IStringProvider& strings, UINT id, Args... args)
+{
+ std::wstring format = strings.GetString(id);
+ if (format.empty()) return L"";
+
+ DWORD_PTR arguments[] = { ToDwordPtr(args)..., 0 };
+
+ LPWSTR buffer = nullptr;
+ FormatMessageW(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_STRING | FORMAT_MESSAGE_ARGUMENT_ARRAY,
+ format.c_str(),
+ 0,
+ 0,
+ reinterpret_cast(&buffer),
+ 0,
+ reinterpret_cast(arguments));
+
+ if (buffer)
+ {
+ std::wstring result(buffer);
+ LocalFree(buffer);
+ return result;
+ }
+ return L"";
+}
+
+std::wstring get_usage(IStringProvider& strings)
+{
+ return strings.GetString(IDS_USAGE);
+}
+
+std::wstring get_json(const std::vector& results)
+{
+ json::JsonObject root;
+ json::JsonArray processes;
+
+ for (const auto& result : results)
+ {
+ json::JsonObject process;
+ process.SetNamedValue(L"pid", json::JsonValue::CreateNumberValue(result.pid));
+ process.SetNamedValue(L"name", json::JsonValue::CreateStringValue(result.name));
+ process.SetNamedValue(L"user", json::JsonValue::CreateStringValue(result.user));
+
+ json::JsonArray files;
+ for (const auto& file : result.files)
+ {
+ files.Append(json::JsonValue::CreateStringValue(file));
+ }
+ process.SetNamedValue(L"files", files);
+
+ processes.Append(process);
+ }
+
+ root.SetNamedValue(L"processes", processes);
+ return root.Stringify().c_str();
+}
+
+std::wstring get_text(const std::vector& results, IStringProvider& strings)
+{
+ std::wstringstream ss;
+ if (results.empty())
+ {
+ ss << strings.GetString(IDS_NO_PROCESSES);
+ return ss.str();
+ }
+
+ ss << strings.GetString(IDS_HEADER);
+ for (const auto& result : results)
+ {
+ ss << result.pid << L"\t"
+ << result.user << L"\t"
+ << result.name << std::endl;
+ }
+ return ss.str();
+}
+
+std::wstring kill_processes(const std::vector& results, IProcessTerminator& terminator, IStringProvider& strings)
+{
+ std::wstringstream ss;
+ for (const auto& result : results)
+ {
+ if (terminator.terminate(result.pid))
+ {
+ ss << FormatString(strings, IDS_TERMINATED, result.pid, result.name.c_str());
+ }
+ else
+ {
+ ss << FormatString(strings, IDS_FAILED_TERMINATE, result.pid, result.name.c_str());
+ }
+ }
+ return ss.str();
+}
+
+CommandResult run_command(int argc, wchar_t* argv[], IProcessFinder& finder, IProcessTerminator& terminator, IStringProvider& strings)
+{
+ Logger::info("Parsing arguments");
+ if (argc < 2)
+ {
+ Logger::warn("No arguments provided");
+ return { 1, get_usage(strings) };
+ }
+
+ bool json_output = false;
+ bool kill = false;
+ bool wait = false;
+ int timeout_ms = -1;
+ std::vector paths;
+
+ for (int i = 1; i < argc; ++i)
+ {
+ std::wstring arg = argv[i];
+ if (arg == L"--json")
+ {
+ json_output = true;
+ }
+ else if (arg == L"--kill")
+ {
+ kill = true;
+ }
+ else if (arg == L"--wait")
+ {
+ wait = true;
+ }
+ else if (arg == L"--timeout")
+ {
+ if (i + 1 < argc)
+ {
+ try
+ {
+ timeout_ms = std::stoi(argv[++i]);
+ }
+ catch (...)
+ {
+ Logger::error("Invalid timeout value");
+ return { 1, strings.GetString(IDS_ERROR_INVALID_TIMEOUT) };
+ }
+ }
+ else
+ {
+ Logger::error("Timeout argument missing");
+ return { 1, strings.GetString(IDS_ERROR_TIMEOUT_ARG) };
+ }
+ }
+ else if (arg == L"--help")
+ {
+ return { 0, get_usage(strings) };
+ }
+ else
+ {
+ paths.push_back(arg);
+ }
+ }
+
+ if (paths.empty())
+ {
+ Logger::error("No paths specified");
+ return { 1, strings.GetString(IDS_ERROR_NO_PATHS) };
+ }
+
+ Logger::info("Processing {} paths", paths.size());
+
+ if (wait)
+ {
+ std::wstringstream ss;
+ if (json_output)
+ {
+ Logger::warn("Wait is incompatible with JSON output");
+ ss << strings.GetString(IDS_WARN_JSON_WAIT);
+ json_output = false;
+ }
+
+ ss << strings.GetString(IDS_WAITING);
+ auto start_time = std::chrono::steady_clock::now();
+ while (true)
+ {
+ auto results = finder.find(paths);
+ if (results.empty())
+ {
+ Logger::info("Files unlocked");
+ ss << strings.GetString(IDS_UNLOCKED);
+ break;
+ }
+
+ if (timeout_ms >= 0)
+ {
+ auto current_time = std::chrono::steady_clock::now();
+ auto elapsed = std::chrono::duration_cast(current_time - start_time).count();
+ if (elapsed > timeout_ms)
+ {
+ Logger::warn("Timeout waiting for files to be unlocked");
+ ss << strings.GetString(IDS_TIMEOUT);
+ return { 1, ss.str() };
+ }
+ }
+
+ Sleep(200);
+ }
+ return { 0, ss.str() };
+ }
+
+ auto results = finder.find(paths);
+ Logger::info("Found {} processes locking the files", results.size());
+ std::wstringstream output_ss;
+
+ if (kill)
+ {
+ Logger::info("Killing processes");
+ output_ss << kill_processes(results, terminator, strings);
+ // Re-check after killing
+ results = finder.find(paths);
+ Logger::info("Remaining processes: {}", results.size());
+ }
+
+ if (json_output)
+ {
+ output_ss << get_json(results) << std::endl;
+ }
+ else
+ {
+ output_ss << get_text(results, strings);
+ }
+
+ return { 0, output_ss.str() };
+}
diff --git a/src/modules/FileLocksmith/FileLocksmithCLI/CLILogic.h b/src/modules/FileLocksmith/FileLocksmithCLI/CLILogic.h
new file mode 100644
index 0000000000..c8f519592f
--- /dev/null
+++ b/src/modules/FileLocksmith/FileLocksmithCLI/CLILogic.h
@@ -0,0 +1,31 @@
+#pragma once
+#include
+#include
+#include "FileLocksmithLib/FileLocksmith.h"
+#include
+
+struct CommandResult
+{
+ int exit_code;
+ std::wstring output;
+};
+
+struct IProcessFinder
+{
+ virtual std::vector find(const std::vector& paths) = 0;
+ virtual ~IProcessFinder() = default;
+};
+
+struct IProcessTerminator
+{
+ virtual bool terminate(DWORD pid) = 0;
+ virtual ~IProcessTerminator() = default;
+};
+
+struct IStringProvider
+{
+ virtual std::wstring GetString(UINT id) = 0;
+ virtual ~IStringProvider() = default;
+};
+
+CommandResult run_command(int argc, wchar_t* argv[], IProcessFinder& finder, IProcessTerminator& terminator, IStringProvider& strings);
diff --git a/src/modules/FileLocksmith/FileLocksmithCLI/FileLocksmithCLI.rc b/src/modules/FileLocksmith/FileLocksmithCLI/FileLocksmithCLI.rc
new file mode 100644
index 0000000000..641c19fb49
--- /dev/null
+++ b/src/modules/FileLocksmith/FileLocksmithCLI/FileLocksmithCLI.rc
@@ -0,0 +1,19 @@
+#include "resource.h"
+#include
+
+STRINGTABLE
+BEGIN
+ IDS_USAGE "Usage: FileLocksmithCLI.exe [options] [path2] ...\nOptions:\n --kill Kill processes locking the files\n --json Output results in JSON format\n --wait Wait for files to be unlocked\n --timeout Timeout in milliseconds for --wait\n --help Show this help message\n"
+ IDS_NO_PROCESSES "No processes found locking the file(s).\n"
+ IDS_HEADER "PID\tUser\tProcess\n"
+ IDS_TERMINATED "Terminated process %1!d! (%2)\n"
+ IDS_FAILED_TERMINATE "Failed to terminate process %1!d! (%2)\n"
+ IDS_FAILED_OPEN "Failed to open process %1!d! (%2)\n"
+ IDS_ERROR_NO_PATHS "Error: No paths specified.\n"
+ IDS_WARN_JSON_WAIT "Warning: --wait is incompatible with --json. Ignoring --json.\n"
+ IDS_WAITING "Waiting for files to be unlocked...\n"
+ IDS_UNLOCKED "Files unlocked.\n"
+ IDS_TIMEOUT "Timeout waiting for files to be unlocked.\n"
+ IDS_ERROR_INVALID_TIMEOUT "Error: Invalid timeout value.\n"
+ IDS_ERROR_TIMEOUT_ARG "Error: --timeout requires an argument.\n"
+END
diff --git a/src/modules/FileLocksmith/FileLocksmithCLI/FileLocksmithCLI.vcxproj b/src/modules/FileLocksmith/FileLocksmithCLI/FileLocksmithCLI.vcxproj
new file mode 100644
index 0000000000..ca85b58d28
--- /dev/null
+++ b/src/modules/FileLocksmith/FileLocksmithCLI/FileLocksmithCLI.vcxproj
@@ -0,0 +1,119 @@
+
+
+
+
+ 17.0
+ Win32Proj
+ {49D456D3-F485-45AF-8875-45B44F193DDC}
+ FileLocksmithCLI
+ 10.0
+ FileLocksmithCLI
+
+
+
+
+ Application
+ true
+ v143
+ Unicode
+
+
+ Application
+ false
+ v143
+ Unicode
+
+
+
+
+
+
+
+
+
+
+
+ ..\..\..\..\$(Platform)\$(Configuration)
+
+
+
+ Level3
+ true
+ WIN32;_DEBUG;_CONSOLE;%(PreprocessorDefinitions)
+ false
+ $(ProjectDir)..;$(ProjectDir)..\..\..;$(ProjectDir)..\..;%(AdditionalIncludeDirectories)
+ Use
+ pch.h
+ false
+
+
+ Console
+ true
+ shlwapi.lib;ntdll.lib;%(AdditionalDependencies)
+
+
+
+
+ Level3
+ true
+ true
+ true
+ WIN32;NDEBUG;_CONSOLE;%(PreprocessorDefinitions)
+ false
+ $(ProjectDir)..;$(ProjectDir)..\..\..;$(ProjectDir)..\..;%(AdditionalIncludeDirectories)
+ Use
+ pch.h
+ false
+
+
+ Console
+ true
+ true
+ true
+ shlwapi.lib;ntdll.lib;%(AdditionalDependencies)
+
+
+
+
+
+
+ Create
+
+
+
+
+
+
+
+
+
+
+
+
+ {9d52fd25-ef90-4f9a-a015-91efc5daf54f}
+
+
+ {d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}
+
+
+ {6955446d-23f7-4023-9bb3-8657f904af99}
+
+
+ {1248566c-272a-43c0-88d6-e6675d569a09}
+
+
+
+
+
+
+
+
+
+
+
+ This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}.
+
+
+
+
+
diff --git a/src/modules/FileLocksmith/FileLocksmithCLI/FileLocksmithCLI.vcxproj.filters b/src/modules/FileLocksmith/FileLocksmithCLI/FileLocksmithCLI.vcxproj.filters
new file mode 100644
index 0000000000..e8a641d95d
--- /dev/null
+++ b/src/modules/FileLocksmith/FileLocksmithCLI/FileLocksmithCLI.vcxproj.filters
@@ -0,0 +1,42 @@
+
+
+
+
+ {4FC737F1-C7A5-4376-A066-2A32D752A2FF}
+ cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx
+
+
+ {93995380-89BD-4b04-88EB-625FBE52EBFB}
+ h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd
+
+
+ {67DA6AB6-F800-4c08-8B7A-83BB121AAD01}
+ rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms
+
+
+
+
+ Source Files
+
+
+ Source Files
+
+
+ Source Files
+
+
+
+
+ Header Files
+
+
+ Header Files
+
+
+ Header Files
+
+
+
+
+
+
diff --git a/src/modules/FileLocksmith/FileLocksmithCLI/main.cpp b/src/modules/FileLocksmith/FileLocksmithCLI/main.cpp
new file mode 100644
index 0000000000..67a4304b4e
--- /dev/null
+++ b/src/modules/FileLocksmith/FileLocksmithCLI/main.cpp
@@ -0,0 +1,71 @@
+#include "pch.h"
+#include "CLILogic.h"
+#include "FileLocksmithLib/FileLocksmith.h"
+#include
+#include "resource.h"
+#include
+#include
+
+struct RealProcessFinder : IProcessFinder
+{
+ std::vector find(const std::vector& paths) override
+ {
+ return find_processes_recursive(paths);
+ }
+};
+
+struct RealProcessTerminator : IProcessTerminator
+{
+ bool terminate(DWORD pid) override
+ {
+ HANDLE hProcess = OpenProcess(PROCESS_TERMINATE, FALSE, pid);
+ if (hProcess)
+ {
+ bool result = TerminateProcess(hProcess, 0);
+ CloseHandle(hProcess);
+ return result;
+ }
+ return false;
+ }
+};
+
+struct RealStringProvider : IStringProvider
+{
+ std::wstring GetString(UINT id) override
+ {
+ wchar_t buffer[4096];
+ int len = LoadStringW(GetModuleHandle(NULL), id, buffer, ARRAYSIZE(buffer));
+ if (len > 0)
+ {
+ return std::wstring(buffer, len);
+ }
+ return L"";
+ }
+};
+
+#ifndef UNIT_TEST
+int wmain(int argc, wchar_t* argv[])
+{
+ winrt::init_apartment();
+ LoggerHelpers::init_logger(L"FileLocksmithCLI", L"", LogSettings::fileLocksmithLoggerName);
+ Logger::info("FileLocksmithCLI started");
+
+ RealProcessFinder finder;
+ RealProcessTerminator terminator;
+ RealStringProvider strings;
+
+ auto result = run_command(argc, argv, finder, terminator, strings);
+
+ if (result.exit_code != 0)
+ {
+ Logger::error("Command failed with exit code {}", result.exit_code);
+ }
+ else
+ {
+ Logger::info("Command succeeded");
+ }
+
+ std::wcout << result.output;
+ return result.exit_code;
+}
+#endif
diff --git a/src/modules/FileLocksmith/FileLocksmithCLI/packages.config b/src/modules/FileLocksmith/FileLocksmithCLI/packages.config
new file mode 100644
index 0000000000..2e5039eb82
--- /dev/null
+++ b/src/modules/FileLocksmith/FileLocksmithCLI/packages.config
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/modules/FileLocksmith/FileLocksmithCLI/pch.cpp b/src/modules/FileLocksmith/FileLocksmithCLI/pch.cpp
new file mode 100644
index 0000000000..1d9f38c57d
--- /dev/null
+++ b/src/modules/FileLocksmith/FileLocksmithCLI/pch.cpp
@@ -0,0 +1 @@
+#include "pch.h"
diff --git a/src/modules/FileLocksmith/FileLocksmithCLI/pch.h b/src/modules/FileLocksmith/FileLocksmithCLI/pch.h
new file mode 100644
index 0000000000..6099342b41
--- /dev/null
+++ b/src/modules/FileLocksmith/FileLocksmithCLI/pch.h
@@ -0,0 +1,22 @@
+#pragma once
+
+#ifndef PCH_H
+#define PCH_H
+
+#define NOMINMAX
+#define WIN32_LEAN_AND_MEAN
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include