Skip to main content

Updated May 4, 2026

Commandlets and automation

Everything covered so far in this folder — Details customizations, factories, Editor Utility Widgets — assumes a human is sitting at the editor, clicking things. A UCommandlet is the opposite shape: a batch operation you invoke from the command line, with no viewport, no actors, no game running, meant to be called by a script, a build step, or a CI pipeline rather than a person. It's how "validate every asset in the project" or "cook this content and fail the build if it errors" becomes a repeatable, scriptable step instead of a manual editor task someone has to remember to run before every release.

Why this matters​

CI pipelines and release processes need operations that run unattended, exit with a meaningful code, and don't require a graphical session. Commandlets are Epic's own answer for this inside the engine itself — the cook process (UCookCommandlet), localization gathering (UGatherTextFromAssetsCommandlet), and shader library packaging all ship as commandlets, invoked the same way your own custom ones are. Writing project-specific commandlets — "validate that every UMyWeaponDefinition asset has a non-zero damage value," "regenerate a data table from a CSV, gate the build if it fails" — gets you the same properties: scriptable, exits with a pass/fail code, runs headless on a build agent that has no display attached.

Mental model​

Epic's own UCommandlet reference is explicit about the execution environment: commandlets run in "a raw environment without the game, client code, or levels loaded, and no actors present." That's the key mental adjustment coming from normal gameplay code — you don't have a UWorld with actors ticking, you have the engine's core systems (asset registry, UObject reflection, config system) and whatever you explicitly load yourself.

The mechanics​

UCommandlet and Main​

Every commandlet overrides Main, documented consistently across Epic's built-in commandlets (UCookCommandlet, UGatherTextFromAssetsCommandlet, UShaderCodeLibraryToolsCommandlet) with the same signature: virtual int32 Main(const FString& Params). Params is the raw command-line string after the commandlet name; you parse it yourself, typically with UCommandlet::ParseCommandLine or FParse::Value.

ValidateWeaponDefinitionsCommandlet.h
UCLASS()
class MYTOOLEDITOR_API UValidateWeaponDefinitionsCommandlet : public UCommandlet
{
GENERATED_BODY()

public:
virtual int32 Main(const FString& Params) override;
};
ValidateWeaponDefinitionsCommandlet.cpp
int32 UValidateWeaponDefinitionsCommandlet::Main(const FString& Params)
{
TArray<FString> Tokens;
TArray<FString> Switches;
TMap<FString, FString> ParamsMap;
ParseCommandLine(*Params, Tokens, Switches, ParamsMap);

const bool bFailOnWarning = Switches.Contains(TEXT("FailOnWarning"));

IAssetRegistry& AssetRegistry =
FModuleManager::LoadModuleChecked<FAssetRegistryModule>("AssetRegistry").Get();

TArray<FAssetData> WeaponAssets;
AssetRegistry.GetAssetsByClass(UMyWeaponDefinition::StaticClass()->GetClassPathName(), WeaponAssets);

int32 NumErrors = 0;
for (const FAssetData& AssetData : WeaponAssets)
{
UMyWeaponDefinition* Weapon = Cast<UMyWeaponDefinition>(AssetData.GetAsset());
if (!Weapon)
{
continue;
}
if (Weapon->BaseDamage <= 0.f)
{
UE_LOG(LogTemp, Error, TEXT("Weapon %s has non-positive BaseDamage"), *Weapon->GetName());
++NumErrors;
}
}

UE_LOG(LogTemp, Display, TEXT("Validated %d weapon definitions, %d errors"),
WeaponAssets.Num(), NumErrors);

return NumErrors > 0 ? 1 : 0; // non-zero exit code fails the CI step
}

Naming and discovery​

ucc.exe-style invocation (or the modern -run= flag) matches by name, and — per Epic's own documentation — automatically appends Commandlet to the name if there's no exact match, so -run=ValidateWeaponDefinitions resolves to UValidateWeaponDefinitionsCommandlet without typing the suffix. The class must live in a module loaded at the point the commandlet is invoked — commonly an Editor-typed module, since most commandlets need editor-only systems (asset registry queries over unbuilt content, cooking, asset validation).

Invoking from the command line​

# Editor-hosted commandlet, project-relative invocation
UnrealEditor-Cmd.exe "D:/Projects/MyGame/MyGame.uproject" -run=ValidateWeaponDefinitions -FailOnWarning

# Built-in cook commandlet, for comparison — same -run= mechanism
UnrealEditor-Cmd.exe "D:/Projects/MyGame/MyGame.uproject" -run=Cook -TargetPlatform=WindowsNoEditor
# CI step: fail the pipeline on non-zero exit code
UnrealEditor-Cmd.exe "$PROJECT_PATH" -run=ValidateWeaponDefinitions -FailOnWarning -unattended -nopause
if [ $? -ne 0 ]; then
echo "Weapon definition validation failed"
exit 1
fi

-unattended suppresses dialogs that would otherwise block waiting for input from a display that doesn't exist on a build agent; -nopause skips the "press any key" prompt some commandlet paths leave on exit.

Build.cs and module placement​

MyToolEditor.Build.cs (excerpt)
PrivateDependencyModuleNames.AddRange(new string[]
{
"UnrealEd", // UCommandlet base class
"AssetRegistry",
"AssetTools",
});

UCommandlet itself is declared in the Engine module (per Epic's API reference, under Runtime/Engine), but most useful commandlet base classes and the systems they need (UEditorEngine, asset registry population from unbuilt content, cook infrastructure) are editor-only in practice — so project commandlets almost always end up living in an Editor-typed module regardless of where the base UCommandlet class itself is declared.

Parsing switches vs. key-value parameters​

ParseCommandLine splits the raw Params string into three buckets, and it's worth being deliberate about which one you use for what: Tokens are bare positional arguments, Switches are flags with no value (-FailOnWarning, -unattended) — note that a key=value pair also lands in Switches as the raw "key=value" string as well as in ParamsMap, so checking Switches.Contains on something that's actually a key-value pair is a common off-by-one bug — and ParamsMap is the parsed key=value map. Prefer FParse::Value(*Params, TEXT("TargetPlatform="), OutValue) for individual key lookups when you don't need the full tokenized breakdown.

Reading a key=value parameter defensively
FString TargetPlatform;
if (!FParse::Value(*Params, TEXT("TargetPlatform="), TargetPlatform))
{
UE_LOG(LogTemp, Error, TEXT("Missing required -TargetPlatform= argument"));
return 1;
}

Gotchas​

There is no UWorld, no actors, no viewport — don't assume gameplay systems are up

Code paths that assume GetWorld() returns something meaningful, or that a GameInstance exists, will either return null or crash inside a commandlet. Stick to asset-registry, UObject-reflection, and config-level operations unless you've explicitly loaded a world yourself.

Exit code is your CI contract — don't let an exception mid-Main lose it

If Main needs to signal failure, return a non-zero int32 deliberately; don't rely on a crash or check() failure to communicate a validation failure, because a hard crash can leave the process exit code ambiguous to the calling script depending on platform and crash-reporter configuration. Catch what you can, log, and return an explicit non-zero value on the failure path shown above.

Commandlets are excluded from some module Types

Recall from Editor-only modules that RuntimeNoCommandlet and EditorNoCommandlet are real Type values specifically meant to exclude a module from commandlet execution. If a commandlet you write depends on a module typed that way, it won't load in the commandlet context — check the Type of every dependency, not just your own commandlet's module, when a commandlet run fails to find a class or subsystem that works fine in the normal editor.

Long-running commandlets still need to tick the engine loop themselves

A commandlet's Main runs to completion and then the process exits — there's no implicit per-frame tick the way there is in a running editor or game. If your commandlet does something that depends on deferred work (asynchronous asset loading, streaming), you're responsible for pumping whatever wait/poll loop is needed before reading the result, rather than assuming it "just happens" between statements.

See also​