Skip to content

Custom Commands.md

eManF edited this page Aug 26, 2026 · 2 revisions

Custom Commands

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).

The command contract

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.

Example 1: a no-input greeting

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.

Example 2: a configurable message command

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.

Example 3: read and write runtime variables

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.

Example 4: a command with conditional options

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.

Example 5: returning flow-control actions

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.

Registering multiple commands

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.

File layout showcase

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.

Testing checklist

  1. Put the module in the app-data commands/ directory.
  2. Restart or relaunch Macro Studio so the loader sees the file.
  3. Confirm the category and command title appear in the command browser.
  4. Add the command and verify its FormBuilder fields.
  5. Save, reopen, and edit the macro to confirm values serialize correctly.
  6. Run it with a harmless test value.
  7. Test cancellation, missing variables, empty strings, and invalid paths.
  8. Check the eDock console if the command does not load; import failures are printed during discovery.

Common mistakes

  • Changing id after 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 using runtime.ui.
  • Forgetting required: False for 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.

Clone this wiki locally