-
Notifications
You must be signed in to change notification settings - Fork 0
Custom Commands.md
Custom commands let you add reusable Python-powered actions to Macro Studio without changing the built-in command library. A custom command can have its own editor fields, variables, previews, flow-control behavior, and runtime integration.
Macro Studio loads Python modules from:
<eDock app data>/emanf.macro-studio/commands/
The loader recursively scans that directory for .py files. Every module must expose a callable named register_macro(registry).
A command is a MacroCommand subclass. The important class attributes are:
| Attribute | Meaning |
|---|---|
id |
Stable identifier saved in macro JSON, for example custom.say_hello
|
title |
Name shown in the command browser |
category |
Category object or category id |
description |
Help text shown for the command |
icon |
eDock icon name |
fields |
FormBuilder schema used when adding/editing the command |
result_policy |
Describes whether the command produces data, a variable, a condition, or control action |
execute() |
Runtime implementation |
display_text() |
Optional readable summary shown in the macro list |
Use an existing built-in command as the source of truth for imports and runtime helpers in your checkout.
This is the smallest useful command. It appears in the command list and shows a message when executed:
from ...core.model.macro_category import MacroCommandCategory
from ...core.model.macro_command import MacroCommand
CustomCategory = MacroCommandCategory(
"custom",
"Custom",
"m:extension",
)
class SayHelloCommand(MacroCommand):
id = "custom.say_hello"
title = "Say Hello"
category = CustomCategory
icon = "m:chat"
description = "Show a greeting while a macro is running."
def execute(self, values=None, runtime=None):
runtime.ui.show_message(
"Custom command",
"Hello from my Macro Studio extension.",
wait=False,
)
return {"shown": True}
def register_macro(registry):
registry.register(SayHelloCommand)runtime.ui.show_message() is safe to call from the runner thread because Macro Studio routes the request to the UI thread.
Fields are declared as data. FormBuilder creates the dialog automatically:
class ShowBannerCommand(MacroCommand):
id = "custom.show_banner"
title = "Show Banner"
category = CustomCategory
icon = "m:message"
description = "Show a configurable message during a macro."
fields = [
{
"name": "title",
"title": "Window Title",
"value_type": "string",
"default_value": "Automation",
"required": True,
},
{
"name": "message",
"title": "Message",
"value_type": "textarea",
"default_value": "The macro reached this step.",
"required": True,
},
{
"name": "wait_until_closed",
"title": "Wait until closed",
"value_type": "boolean",
"default_value": False,
"required": False,
},
]
def display_text(self, values=None):
values = self.normalize_values(values)
return f"show banner: {values.get('title', 'Automation')}"
def execute(self, values=None, runtime=None):
values = self.normalize_values(values)
runtime.ui.show_message(
values.get("title", "Automation"),
values.get("message", ""),
wait=bool(values.get("wait_until_closed", False)),
)
return {"shown": True}When the user adds this command, the generated form contains a text field, multiline message field, and checkbox. The macro list displays the human-readable summary returned by display_text().
See FormBuilder for conditional fields, validation, runtime variables, and custom field handlers.
Runtime variables are available through runtime.vars. Use the variable store rather than a module-level global so the command works correctly inside loops and nested macro groups:
from ...core.model.macro_command import MacroCommand, ResultPolicy
class BuildGreetingCommand(MacroCommand):
id = "custom.build_greeting"
title = "Build Greeting"
category = CustomCategory
icon = "m:code"
description = "Create a greeting from a name variable."
result_policy = ResultPolicy.VARIABLE
fields = [
{
"name": "name_variable",
"title": "Name Variable",
"value_type": "variable",
"default_value": "user_name",
},
{
"name": "result_variable",
"title": "Save Result To",
"value_type": "variable",
"default_value": "greeting",
},
]
def execute(self, values=None, runtime=None):
values = self.normalize_values(values)
name = runtime.vars.get(values["name_variable"], "")
greeting = f"Hello, {name or 'there'}!"
target = values["result_variable"]
runtime.vars.add(target)
runtime.vars.set(target, greeting)
return {
"variable_name": target,
"value": greeting,
}The variable field automatically receives the project's current variable choices when Macro Studio opens the form.
Use FormBuilder rules when a field only makes sense in a particular mode:
class WaitForSourceCommand(MacroCommand):
id = "custom.wait_for_source"
title = "Wait for Source"
category = CustomCategory
description = "Wait for either a fixed number of seconds or a variable."
fields = [
{
"name": "source",
"title": "Wait Source",
"value_type": "choice",
"options": ["seconds", "variable"],
"default_value": "seconds",
},
{
"name": "seconds",
"title": "Seconds",
"value_type": "float",
"default_value": 1.0,
"visible_if": {
"field": "source",
"operator": "==",
"value": "seconds",
},
},
{
"name": "seconds_variable",
"title": "Seconds Variable",
"value_type": "variable",
"default_value": "",
"visible_if": {
"field": "source",
"operator": "==",
"value": "variable",
},
},
]
def execute(self, values=None, runtime=None):
values = self.normalize_values(values)
if values["source"] == "variable":
seconds = float(runtime.vars.get(values["seconds_variable"], 0) or 0)
else:
seconds = float(values["seconds"] or 0)
# For a production command, follow the cooperative wait pattern used
# by the built-in timing commands in this checkout.
import time
time.sleep(max(0, seconds))
return {"seconds": seconds}The form shows only the controls relevant to the selected source. For a real command, follow the wait implementation used by the built-in timing commands in your checkout.
Commands can return structured results that the runner interprets. This is how a command can request a jump or stop:
class StopIfEmptyCommand(MacroCommand):
id = "custom.stop_if_empty"
title = "Stop If Empty"
category = CustomCategory
description = "Stop the current macro when a variable is empty."
fields = [
{
"name": "variable_name",
"title": "Variable",
"value_type": "variable",
"default_value": "",
},
]
def execute(self, values=None, runtime=None):
values = self.normalize_values(values)
value = runtime.vars.get(values["variable_name"], "")
if value in (None, ""):
return {
"action": "exit_current_macro",
"reason": f"{values['variable_name']} is empty",
}
return {"action": "continue"}Only return actions supported by the runtime. For example, the built-in runtime understands actions such as exit_current_macro, stop_entire_run, jump_to_comment, run_macro_group, and loop actions. Inspect the flow-control commands and macro_executor.py before introducing a new action.
One module can register several commands:
def register_macro(registry):
registry.register(SayHelloCommand)
registry.register(ShowBannerCommand)
registry.register(BuildGreetingCommand)The registry accepts either a command class or an instance. Classes are usually preferable because the registry creates a clean command object during loading.
For a small extension:
<eDock app data>/emanf.macro-studio/commands/
└── personal_tools.py
For a larger extension, organize modules by category:
<eDock app data>/emanf.macro-studio/commands/
└── personal/
├── __init__.py
├── notifications.py
└── files.py
The loader scans subdirectories, but avoid helper files that look like commands unless they intentionally expose register_macro.
- Put the module in the app-data
commands/directory. - Restart or relaunch Macro Studio so the loader sees the file.
- Confirm the category and command title appear in the command browser.
- Add the command and verify its FormBuilder fields.
- Save, reopen, and edit the macro to confirm values serialize correctly.
- Run it with a harmless test value.
- Test cancellation, missing variables, empty strings, and invalid paths.
- Check the eDock console if the command does not load; import failures are printed during discovery.
- Changing
idafter users have saved macros; the old items will become unknown commands. - Reading variables with a Python global instead of
runtime.vars. - Calling Qt widgets directly from
execute()instead of usingruntime.ui. - Forgetting
required: Falsefor informational or optional fields. - Returning undocumented flow actions.
- Assuming a command is portable when it depends on a particular OS, window manager, or external package.
- Loading untrusted custom modules; they run with the same permissions as eDock.
Custom commands are application code, not sandboxed scripts. Keep them small, document their side effects, and only install code you trust.