From 14b8e67c9069a13644feb4c1af07c857557bbb82 Mon Sep 17 00:00:00 2001
From: Cyril Bissey <53737317+Cykyrios@users.noreply.github.com>
Date: Sat, 8 Nov 2025 10:53:06 +0100
Subject: [PATCH] Add CLI support
Adds a CLI panel to the GUI, and allows launching with `--headless` for
full terminal I/O. Currently supports (dis)connecting, listing enabled
and available modules, as well as enabling, disabling, and reloading
modules.
---
.submodules/godot_insim | 2 +-
src/cli/cli.gd | 204 ++++++++++++++++++
src/cli/cli.gd.uid | 1 +
src/cli/commands/cli_argument.gd | 34 +++
src/cli/commands/cli_argument.gd.uid | 1 +
src/cli/commands/cli_command.gd | 67 ++++++
src/cli/commands/cli_command.gd.uid | 1 +
src/cli/commands/cli_option.gd | 10 +
src/cli/commands/cli_option.gd.uid | 1 +
src/cli/commands/cli_parsed_argument.gd | 66 ++++++
src/cli/commands/cli_parsed_argument.gd.uid | 1 +
src/cli/commands/cli_parsed_command.gd | 90 ++++++++
src/cli/commands/cli_parsed_command.gd.uid | 1 +
src/gis_hub.gd | 50 ++++-
src/modules/gis_hub/gis_hub_cli.gd | 24 +++
src/modules/gis_hub/gis_hub_cli.gd.uid | 1 +
src/modules/gis_hub/gis_hub_cli.tscn | 33 +++
src/modules/gis_hub/module_gis_hub.gd | 32 +--
src/modules/gis_hub/module_gis_hub.tscn | 8 +-
src/modules/gis_module.gd | 101 ++++++++-
test/cli/test_gis_hub_cli.gd | 101 +++++++++
test/cli/test_gis_hub_cli.gd.uid | 1 +
.../advanced_modules/advanced_modules.md | 96 +++++++++
23 files changed, 910 insertions(+), 16 deletions(-)
create mode 100644 src/cli/cli.gd
create mode 100644 src/cli/cli.gd.uid
create mode 100644 src/cli/commands/cli_argument.gd
create mode 100644 src/cli/commands/cli_argument.gd.uid
create mode 100644 src/cli/commands/cli_command.gd
create mode 100644 src/cli/commands/cli_command.gd.uid
create mode 100644 src/cli/commands/cli_option.gd
create mode 100644 src/cli/commands/cli_option.gd.uid
create mode 100644 src/cli/commands/cli_parsed_argument.gd
create mode 100644 src/cli/commands/cli_parsed_argument.gd.uid
create mode 100644 src/cli/commands/cli_parsed_command.gd
create mode 100644 src/cli/commands/cli_parsed_command.gd.uid
create mode 100644 src/modules/gis_hub/gis_hub_cli.gd
create mode 100644 src/modules/gis_hub/gis_hub_cli.gd.uid
create mode 100644 src/modules/gis_hub/gis_hub_cli.tscn
create mode 100644 test/cli/test_gis_hub_cli.gd
create mode 100644 test/cli/test_gis_hub_cli.gd.uid
diff --git a/.submodules/godot_insim b/.submodules/godot_insim
index a2f0191..da41c8f 160000
--- a/.submodules/godot_insim
+++ b/.submodules/godot_insim
@@ -1 +1 @@
-Subproject commit a2f0191c1416feee5a903f6d6a07263fdc559d2f
+Subproject commit da41c8f75abd15fb5fd45d947108ce6640cbabaf
diff --git a/src/cli/cli.gd b/src/cli/cli.gd
new file mode 100644
index 0000000..7bde542
--- /dev/null
+++ b/src/cli/cli.gd
@@ -0,0 +1,204 @@
+class_name GISHubCLI
+extends Node
+
+signal command_sent(parsed_cmd: GISHubCLIParsedCommand)
+signal message_written(message: String)
+
+var headless := false
+var known_commands: Array[GISHubCLICommand] = []
+
+var _thread: Thread = null
+
+
+func _ready() -> void:
+ _add_default_cli_commands()
+
+ if DisplayServer.get_name() == "headless":
+ headless = true
+ _start_cli_thread()
+
+
+func _exit_tree() -> void:
+ if _thread and _thread.is_started() and not _thread.is_alive():
+ _thread.wait_to_finish()
+
+
+func add_command(command: GISHubCLICommand) -> void:
+ known_commands.append(command)
+
+
+func emit_error(message: String) -> void:
+ message_written.emit(_replace_message_color(message, Color.RED))
+
+
+func emit_message(message: String) -> void:
+ message_written.emit(message)
+
+
+func emit_warning(message: String) -> void:
+ message_written.emit(_replace_message_color(message, Color.YELLOW))
+
+
+func execute_command(gis_hub: GISHub, command: GISHubCLIParsedCommand) -> void:
+ var subcommands := _get_known_subcommands(command.name)
+ if (
+ command.subcommand.is_empty() and not subcommands.is_empty()
+ or not command.subcommand.is_empty() and command.subcommand not in subcommands
+ ):
+ emit_error("%s: Unknown subcommand '%s', expected one of [code]'%s'[/code]\n" % [
+ "[code]%s[/code]" % [command.name],
+ "[code]%s[/code]" % [
+ command.subcommand if not command.subcommand.is_empty()
+ else str(command.arguments[0]) if not command.arguments.is_empty()
+ else "",
+ ],
+ " ".join(_get_known_subcommands(command.name)),
+ ])
+ return
+ match command.name:
+ "connect":
+ gis_hub._module_gis_hub.connect_insim.call_deferred()
+ "disconnect":
+ gis_hub._module_gis_hub.disconnect_insim.call_deferred()
+ "module":
+ execute_command_module(gis_hub, command)
+ _:
+ push_error("%s: command not found" % [command.name])
+
+
+func execute_command_module(gis_hub: GISHub, command: GISHubCLIParsedCommand) -> void:
+ match command.subcommand:
+ "command":
+ var header := "[code]module command[/code]"
+ if command.arguments.is_empty():
+ emit_error(
+ "%s: Expected module name" % [header]
+ + " followed by module command.\n"
+ )
+ return
+ var module_arg := command.arguments[0]
+ var module_name := command.arguments[0].value
+ if (
+ module_arg.type != module_arg.Type.ARGUMENT_UNNAMED
+ or module_name not in gis_hub._module_manager.get_enabled_modules()
+ ):
+ emit_error(
+ "%s: First argument must be an enabled module's name.\n" % [header]
+ )
+ return
+ for module in gis_hub._get_active_modules():
+ if module.get_module_name() == module_name:
+ if command.arguments.size() == 1:
+ emit_error(
+ "%s: No command was given to module %s.\n" % [header, module_name]
+ )
+ return
+ module.execute_command(command)
+ return
+ emit_error("%s: Module %s doesn't exist or is not enabled.\n" % [header, module_name])
+ "disable", "enable", "reload":
+ var modules: Array[String] = []
+ modules.assign(command.arguments.map(
+ func(arg: GISHubCLIParsedArgument) -> String: return arg.value
+ ))
+ if command.subcommand == "disable":
+ gis_hub._module_gis_hub.request_disable_modules(modules)
+ elif command.subcommand == "enable":
+ gis_hub._module_gis_hub.request_enable_modules(modules)
+ elif command.subcommand == "reload":
+ gis_hub._module_gis_hub.request_reload_modules(modules)
+ "list":
+ var enabled_modules := gis_hub._module_manager.get_enabled_modules()
+ for i in enabled_modules.size():
+ if enabled_modules[i].contains(" "):
+ enabled_modules[i] = '"%s"' % [enabled_modules[i]]
+ var available_modules: Array[String] = []
+ available_modules.assign(
+ gis_hub._module_manager.get_available_modules().map(
+ func(metadata: GISModuleMetadata) -> String: return metadata.module_name
+ )
+ )
+ for i in available_modules.size():
+ if available_modules[i].contains(" "):
+ available_modules[i] = '"%s"' % [available_modules[i]]
+ emit_message("Enabled modules: %s\nAvailable modules: %s\n" % [
+ " ".join(enabled_modules),
+ " ".join(available_modules),
+ ])
+
+
+func parse_command(command: String) -> void:
+ var splits := command.split(" ")
+ var parsed_cmd: GISHubCLIParsedCommand = null
+ for cmd in known_commands:
+ if splits.size() >= 1 and splits[0] == cmd.name:
+ if (
+ cmd.subcommand.is_empty()
+ or splits.size() >= 2 and splits[1] == cmd.subcommand
+ ):
+ parsed_cmd = GISHubCLIParsedCommand.new(command, cmd)
+ break
+ if not parsed_cmd:
+ parsed_cmd = GISHubCLIParsedCommand.new(command)
+ var command_names := known_commands.map(
+ func(cmd: GISHubCLICommand) -> String: return cmd.name
+ )
+ if parsed_cmd.name not in command_names:
+ emit_error.call_deferred(
+ "[bgcolor=black][code]%s[/code][/bgcolor]" % [str(command)]
+ + " is not a recognized command.\n"
+ )
+ else:
+ command_sent.emit.call_deferred(parsed_cmd)
+
+
+func _add_default_cli_commands() -> void:
+ add_command(GISHubCLICommand.create("module", "command"))
+ add_command(GISHubCLICommand.create("module", "disable"))
+ add_command(GISHubCLICommand.create("module", "enable"))
+ add_command(GISHubCLICommand.create("module", "list"))
+ add_command(GISHubCLICommand.create("module", "reload"))
+ add_command(GISHubCLICommand.create("connect"))
+ add_command(GISHubCLICommand.create("disconnect"))
+
+
+func _get_known_command(command_name: String, subcommand_name := "") -> GISHubCLICommand:
+ for cmd in known_commands:
+ if cmd.name == command_name and cmd.subcommand == subcommand_name:
+ return cmd
+ return null
+
+
+func _get_known_subcommands(command_name: String) -> Array[String]:
+ var subcommands: Array[String] = []
+ for cmd in known_commands:
+ if cmd.name == command_name and not cmd.subcommand.is_empty():
+ subcommands.append(cmd.subcommand)
+ return subcommands
+
+
+func _process_cli_input() -> void:
+ while true:
+ parse_command(OS.read_string_from_stdin().strip_edges())
+
+
+func _replace_message_color(message: String, color: Color) -> String:
+ var color_tag := "[color=%s]" % [color.to_html(false)]
+ message = RegEx.create_from_string(r"\[/color](?!\[color=)").sub(
+ message, "[/color]" + color_tag, true
+ )
+ message = RegEx.create_from_string(r"(? void:
+ _thread = Thread.new()
+ var error := _thread.start(_process_cli_input)
+ if error != OK:
+ push_error("Failed to create CLI input thread")
diff --git a/src/cli/cli.gd.uid b/src/cli/cli.gd.uid
new file mode 100644
index 0000000..756b7a5
--- /dev/null
+++ b/src/cli/cli.gd.uid
@@ -0,0 +1 @@
+uid://v0y6l1cucyo4
diff --git a/src/cli/commands/cli_argument.gd b/src/cli/commands/cli_argument.gd
new file mode 100644
index 0000000..bf45a8f
--- /dev/null
+++ b/src/cli/commands/cli_argument.gd
@@ -0,0 +1,34 @@
+class_name GISHubCLIArgument
+extends RefCounted
+## CLI command argument
+##
+## This class contains data about a [GISHubCLICommand]'s argument, intended especially
+## for named arguments and options, as well as arguments only allowing a specific set
+## of allowed values.
+
+enum Type {
+ ARGUMENT,
+ OPTION,
+}
+
+## The argument's type (argument or option).
+var type := Type.ARGUMENT
+## The argument's full name (to be called with the [code]--[/code] prefix).
+var full_name := ""
+## The argument's short name (to be called with the [code]-[/code] prefix), typically
+## a single letter.
+var short_name := ""
+## The set of allowed values for this argument. If empty, values are unrestricted.
+var valid_values: Array[String] = []
+
+
+## Creates and returns a new argument. Intended to be used when creating a [GISHubCLICommand].
+static func create(
+ arg_name: String, arg_short := "", arg_values: Array[String] = []
+) -> GISHubCLIArgument:
+ var argument := GISHubCLIArgument.new()
+ argument.full_name = arg_name
+ argument.short_name = arg_short
+ if not arg_values.is_empty():
+ argument.valid_values = arg_values.duplicate()
+ return argument
diff --git a/src/cli/commands/cli_argument.gd.uid b/src/cli/commands/cli_argument.gd.uid
new file mode 100644
index 0000000..113c060
--- /dev/null
+++ b/src/cli/commands/cli_argument.gd.uid
@@ -0,0 +1 @@
+uid://bliqmmitnd7r2
diff --git a/src/cli/commands/cli_command.gd b/src/cli/commands/cli_command.gd
new file mode 100644
index 0000000..904fd66
--- /dev/null
+++ b/src/cli/commands/cli_command.gd
@@ -0,0 +1,67 @@
+class_name GISHubCLICommand
+extends RefCounted
+## Command line interface command definition
+##
+## This class is used to register internal GIS Hub commands, as well as [GISModule]
+## custom commands.
+
+## The command's name.
+var name := "my_command"
+## An optional subcommand, to be used as the command's first argument. Multiple
+## command/subcommand pairs can be registered for a single command name.
+var subcommand := ""
+## The command's arguments (specific to the [member subcommand] if given).
+var arguments: Array[GISHubCLIArgument] = []
+
+
+## Creates and returns a new command. This is the intended way of registering custom
+## module commands (see [method GISModule.register_command]).
+static func create(
+ cmd_name: String, cmd_subcommand := "", ...cmd_args: Array
+) -> GISHubCLICommand:
+ var new_command := GISHubCLICommand.new()
+ new_command.name = cmd_name
+ new_command.subcommand = cmd_subcommand
+ new_command.validate_name()
+ for arg in cmd_args as Array[GISHubCLIArgument]:
+ new_command.arguments.append(arg)
+ return new_command
+
+
+## Returns [code]true[/code] if the given [param argument_name] exists as one of
+## the [member arguments]' name. Returns [code]false[/code] for unnamed arguments
+## and options (value-less arguments).
+func has_argument(argument_name: String) -> bool:
+ var arg := argument_name.lstrip("-")
+ for argument in arguments:
+ if (
+ (arg == argument.full_name or arg == argument.short_name)
+ and argument.type == argument.Type.ARGUMENT
+ ):
+ return true
+ return false
+
+
+## Returns [code]true[/code] if the given [param option_name] exists as one of the
+## [member arguments]' name. Returns [code]false[/code] if the argument has a value.
+func has_option(option_name: String) -> bool:
+ var arg := option_name.lstrip("-")
+ for argument in arguments:
+ if (
+ (arg == argument.full_name or arg == argument.short_name)
+ and argument.type == argument.Type.OPTION
+ ):
+ return true
+ return false
+
+
+## Replaces all characters other than lowercase letters, digits, underscores, and hyphens
+## with underscores, for both the [member name] and the [member subcommand].
+func validate_name() -> void:
+ var regex := RegEx.create_from_string(r"[^a-z1-9_\-]")
+ if regex.search(name):
+ push_warning("Command '%s' will be sanitized." % [name])
+ name = regex.sub(name, "_", true)
+ if regex.search(subcommand):
+ push_warning("Command '%s' subcommand '%s' will be sanitized." % [name, subcommand])
+ subcommand = regex.sub(subcommand, "_", true)
diff --git a/src/cli/commands/cli_command.gd.uid b/src/cli/commands/cli_command.gd.uid
new file mode 100644
index 0000000..d9e4239
--- /dev/null
+++ b/src/cli/commands/cli_command.gd.uid
@@ -0,0 +1 @@
+uid://b28sdf75hsmoi
diff --git a/src/cli/commands/cli_option.gd b/src/cli/commands/cli_option.gd
new file mode 100644
index 0000000..5b1a773
--- /dev/null
+++ b/src/cli/commands/cli_option.gd
@@ -0,0 +1,10 @@
+class_name GISHubCLIOption
+extends GISHubCLIArgument
+
+
+static func create(opt_name: String, opt_short := "", _opt_values := []) -> GISHubCLIOption:
+ var option := GISHubCLIOption.new()
+ option.type = Type.OPTION
+ option.full_name = opt_name
+ option.short_name = opt_short
+ return option
diff --git a/src/cli/commands/cli_option.gd.uid b/src/cli/commands/cli_option.gd.uid
new file mode 100644
index 0000000..a5a51da
--- /dev/null
+++ b/src/cli/commands/cli_option.gd.uid
@@ -0,0 +1 @@
+uid://cralo1u7a5uxh
diff --git a/src/cli/commands/cli_parsed_argument.gd b/src/cli/commands/cli_parsed_argument.gd
new file mode 100644
index 0000000..0fadbc3
--- /dev/null
+++ b/src/cli/commands/cli_parsed_argument.gd
@@ -0,0 +1,66 @@
+class_name GISHubCLIParsedArgument
+extends RefCounted
+## Parsed command argument
+##
+## This class holds data about the arguments passed to a [GISHubCLIParsedCommand],
+## for use in custom module commands.
+
+enum Type {
+ OPTION_FULL, ## A full-name option (e.g. [code]--my-option[/code])
+ OPTION_SHORT, ## A short-name option (e.g. [code]-o[/code])
+ ARGUMENT_FULL, ## A full-name argument (e.g. [code]--my-arg value[/code])
+ ARGUMENT_SHORT, ## A short-name option (e.g. [code]-a value[/code])
+ ARGUMENT_UNNAMED, ## An unnamed argument, i.e. a value only
+ UNKNOWN, ## An argument that was not parsed properly.
+}
+
+## The argument's name.
+var name := ""
+## The argument's type.
+var type := Type.UNKNOWN
+## The argument's value.
+var value := ""
+
+
+func _init(arg_name: String, arg_value := "") -> void:
+ if arg_name.begins_with("--"):
+ if arg_value.strip_edges().is_empty():
+ type = Type.OPTION_FULL
+ else:
+ type = Type.ARGUMENT_FULL
+ elif arg_name.begins_with("-") and arg_name.length() == 2:
+ if arg_value.strip_edges().is_empty():
+ type = Type.OPTION_SHORT
+ else:
+ type = Type.ARGUMENT_SHORT
+ elif not arg_value.is_empty():
+ type = Type.ARGUMENT_UNNAMED
+ if type in [Type.ARGUMENT_FULL, Type.ARGUMENT_SHORT, Type.ARGUMENT_UNNAMED]:
+ value = arg_value
+ name = arg_name.lstrip("-")
+
+
+func _to_string() -> String:
+ return (
+ value if type == Type.ARGUMENT_UNNAMED
+ else name if type in [Type.OPTION_FULL, Type.OPTION_SHORT]
+ else " ".join([name, value])
+ )
+
+
+func is_valid() -> bool:
+ if type == Type.UNKNOWN:
+ return false
+ if type in [
+ Type.OPTION_FULL,
+ Type.OPTION_SHORT,
+ Type.ARGUMENT_FULL,
+ Type.ARGUMENT_SHORT,
+ ] and name.strip_edges().is_empty():
+ return false
+ if (
+ type == Type.ARGUMENT_UNNAMED
+ and (not name.is_empty() or value.is_empty())
+ ):
+ return false
+ return true
diff --git a/src/cli/commands/cli_parsed_argument.gd.uid b/src/cli/commands/cli_parsed_argument.gd.uid
new file mode 100644
index 0000000..353db48
--- /dev/null
+++ b/src/cli/commands/cli_parsed_argument.gd.uid
@@ -0,0 +1 @@
+uid://babdopbiiib0w
diff --git a/src/cli/commands/cli_parsed_command.gd b/src/cli/commands/cli_parsed_command.gd
new file mode 100644
index 0000000..9597a24
--- /dev/null
+++ b/src/cli/commands/cli_parsed_command.gd
@@ -0,0 +1,90 @@
+class_name GISHubCLIParsedCommand
+extends RefCounted
+## Parsed command line call
+##
+## This class contains the full call to a command made via the CLI panel or when running
+## in headless mode, including the list of arguments and options. It is not intended to be
+## created manually, but only used to retrieve the arguments.[br]
+## Arguments are determined by comparing the command name to a registered command defined
+## as a [GISHubCLICommand] (you can register such commands for your modules by calling
+## [method GISModule.register_command], typically in [method GISModule._register_commands]).
+
+## The command's name.
+var name := ""
+## The subcommand, if any.
+var subcommand := ""
+## The arguments passed to the command (the [member subcommand] is not
+## considered as an argument).
+var arguments: Array[GISHubCLIParsedArgument] = []
+
+
+func _init(input: String, known_command: GISHubCLICommand = null) -> void:
+ var parsed_args := _unescape_input(input)
+ if parsed_args.is_empty():
+ return
+ name = parsed_args[0]
+ parsed_args.remove_at(0)
+ var skip_next := false
+ for i in parsed_args.size():
+ if skip_next:
+ skip_next = false
+ continue
+ var arg := parsed_args[i]
+ if (
+ i == 0
+ and known_command != null
+ and not known_command.subcommand.is_empty()
+ and arg == known_command.subcommand
+ ):
+ subcommand = arg
+ elif arg.begins_with("-"):
+ if (
+ i < parsed_args.size() - 1
+ and known_command != null
+ and known_command.has_argument(arg)
+ ):
+ skip_next = true
+ arguments.append(GISHubCLIParsedArgument.new(arg, parsed_args[i + 1]))
+ else:
+ arguments.append(GISHubCLIParsedArgument.new(arg))
+ else:
+ arguments.append(GISHubCLIParsedArgument.new("", arg))
+
+
+func _to_string() -> String:
+ var command_string := name
+ if not subcommand.is_empty():
+ command_string += " %s" % [subcommand]
+ for arg in arguments:
+ command_string += " %s" % [arg]
+ return command_string
+
+
+func _unescape_input(input: String) -> Array[String]:
+ var parsed_args: Array[String] = []
+ var splits := input.split(" ")
+ var quotes_open := false
+ var i := 0
+ while i < splits.size():
+ var arg := splits[i]
+ if not quotes_open:
+ parsed_args.append(arg)
+ if arg.begins_with("\""):
+ quotes_open = true
+ parsed_args[-1] = parsed_args[-1].trim_prefix("\"")
+ if arg.ends_with("\""):
+ quotes_open = false
+ parsed_args[-1] = parsed_args[-1].trim_suffix("\"")
+ else:
+ parsed_args[-1] = " ".join([parsed_args[-1], arg])
+ if arg.ends_with("\""):
+ quotes_open = false
+ parsed_args[-1] = parsed_args[-1].trim_suffix("\"")
+ while splits[i].ends_with("\\") and i < splits.size() - 1:
+ parsed_args[-1] = " ".join([parsed_args[-1].trim_suffix("\\"), splits[i + 1]])
+ i += 1
+ if splits[i].ends_with("\""):
+ quotes_open = false
+ parsed_args[-1] = parsed_args[-1].trim_suffix("\"")
+ i += 1
+ return parsed_args
diff --git a/src/cli/commands/cli_parsed_command.gd.uid b/src/cli/commands/cli_parsed_command.gd.uid
new file mode 100644
index 0000000..f404c26
--- /dev/null
+++ b/src/cli/commands/cli_parsed_command.gd.uid
@@ -0,0 +1 @@
+uid://m6lmslvysvdn
diff --git a/src/gis_hub.gd b/src/gis_hub.gd
index 418d21f..44d6045 100644
--- a/src/gis_hub.gd
+++ b/src/gis_hub.gd
@@ -20,6 +20,8 @@ var _module_log: ModuleLog = null
# Used to invalidate current InSim settings (init flags and interval) until they can be changed.
var _insim_settings_dirty := false
+var _cli_handler: GISHubCLI = null
+
@onready var _tab_container: TabContainer = %TabContainer
@@ -110,6 +112,17 @@ func _ready() -> void:
if gis_config and gis_config.auto_connect:
_module_gis_hub.connect_insim()
+ if DisplayServer.get_name() == "headless":
+ _cli_handler = GISHubCLI.new()
+ add_child(_cli_handler)
+ else:
+ _cli_handler = _module_gis_hub.cli_foldable_container._cli_handler
+ _connect = _cli_handler.command_sent.connect(_on_cli_command_sent)
+ _connect = _cli_handler.message_written.connect(_on_cli_message_written)
+ _connect = _insim.log_message_written.connect(_on_insim_log_message_written)
+ _connect = _insim.log_error_written.connect(_on_insim_log_error_written)
+ _connect = _insim.log_warning_written.connect(_on_insim_log_warning_written)
+
func _exit_tree() -> void:
if _insim.insim_connected:
@@ -133,7 +146,8 @@ func _connect_insim_signals() -> void:
func _initialize_module(module_instance: GISModule) -> void:
_assign_req_i(module_instance)
module_instance.hub_insim = _insim
- var _connect := module_instance.custom_data_sent.connect(_on_module_sent_custom_data)
+ var _connect := module_instance.cli_message_posted.connect(_on_module_wrote_to_cli)
+ _connect = module_instance.custom_data_sent.connect(_on_module_sent_custom_data)
_connect = module_instance.insim_connect_requested.connect(_module_gis_hub.connect_insim)
_connect = module_instance.insim_disconnect_requested.connect(_module_gis_hub.disconnect_insim)
_connect = module_instance.packet_sent.connect(_on_module_sent_packet)
@@ -257,6 +271,17 @@ func _update_insim_settings_from_module_requirements(module: GISModule) -> void:
_module_gis_hub.insim_settings.interval_spinbox.value = requirements.interval
+func _on_cli_command_sent(command: GISHubCLIParsedCommand) -> void:
+ _cli_handler.execute_command(self, command)
+
+
+func _on_cli_message_written(message: String) -> void:
+ if _cli_handler.headless:
+ print_rich(message.trim_suffix("\n"))
+ else:
+ _module_gis_hub.write_to_cli(message)
+
+
func _on_disable_modules_requested(modules: Array[String]) -> void:
var disabled_modules := _module_manager.disable_modules(modules)
for module_name in disabled_modules:
@@ -310,6 +335,22 @@ func _on_insim_disconnected() -> void:
_update_insim_settings()
+func _on_insim_log_error_written(message: String) -> void:
+ _on_insim_log_message_written("[color=red]" + message + "[/color]")
+
+
+func _on_insim_log_message_written(message: String) -> void:
+ if _cli_handler.headless:
+ return
+ if not message.ends_with("\n"):
+ message += "\n"
+ _module_gis_hub.cli_foldable_container.write_message(message)
+
+
+func _on_insim_log_warning_written(message: String) -> void:
+ _on_insim_log_message_written("[color=goldenrod]" + message + "[/color]")
+
+
func _on_module_sent_custom_data(
module_name: String, target_module: String, identifier: String, data: Array
) -> void:
@@ -344,6 +385,13 @@ func _on_module_sent_packet(packet: InSimPacket, sender: String) -> void:
_insim.send_packet(packet, sender)
+func _on_module_wrote_to_cli(message: String) -> void:
+ print_rich(message)
+ if not _cli_handler.headless:
+ _module_gis_hub.write_to_cli(message)
+ _module_gis_hub.write_cli_message(message)
+
+
func _on_packet_received(packet: InSimPacket) -> void:
for module in _get_active_modules():
module._on_packet_received(packet)
diff --git a/src/modules/gis_hub/gis_hub_cli.gd b/src/modules/gis_hub/gis_hub_cli.gd
new file mode 100644
index 0000000..64b8597
--- /dev/null
+++ b/src/modules/gis_hub/gis_hub_cli.gd
@@ -0,0 +1,24 @@
+class_name ModuleGISHubCLI
+extends FoldableContainer
+
+var _cli_handler: GISHubCLI = null
+
+@onready var rich_text_label: RichTextLabel = %RichTextLabel
+@onready var line_edit: LineEdit = %LineEdit
+
+
+func _ready() -> void:
+ if DisplayServer.get_name() == "headless":
+ return
+ _cli_handler = GISHubCLI.new()
+ add_child(_cli_handler)
+
+ var _connect := line_edit.text_submitted.connect(_on_command_submitted)
+
+
+func write_message(message: String) -> void:
+ rich_text_label.append_text(message)
+
+
+func _on_command_submitted(command: String) -> void:
+ _cli_handler.parse_command(command)
diff --git a/src/modules/gis_hub/gis_hub_cli.gd.uid b/src/modules/gis_hub/gis_hub_cli.gd.uid
new file mode 100644
index 0000000..d1203af
--- /dev/null
+++ b/src/modules/gis_hub/gis_hub_cli.gd.uid
@@ -0,0 +1 @@
+uid://ck65p3jewiub
diff --git a/src/modules/gis_hub/gis_hub_cli.tscn b/src/modules/gis_hub/gis_hub_cli.tscn
new file mode 100644
index 0000000..6340360
--- /dev/null
+++ b/src/modules/gis_hub/gis_hub_cli.tscn
@@ -0,0 +1,33 @@
+[gd_scene load_steps=3 format=3 uid="uid://chwlwurv6b4qe"]
+
+[ext_resource type="Script" uid="uid://ck65p3jewiub" path="res://src/modules/gis_hub/gis_hub_cli.gd" id="1_it0kh"]
+
+[sub_resource type="SystemFont" id="SystemFont_it0kh"]
+font_names = PackedStringArray("CommitMono Nerd Font", "Monospace")
+font_weight = 600
+generate_mipmaps = true
+subpixel_positioning = 0
+
+[node name="CLIFoldableContainer" type="FoldableContainer"]
+title = "CLI"
+script = ExtResource("1_it0kh")
+
+[node name="VBoxContainer" type="VBoxContainer" parent="."]
+layout_mode = 2
+
+[node name="RichTextLabel" type="RichTextLabel" parent="VBoxContainer"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(400, 200)
+layout_mode = 2
+focus_mode = 2
+theme_override_fonts/mono_font = SubResource("SystemFont_it0kh")
+bbcode_enabled = true
+scroll_following = true
+context_menu_enabled = true
+selection_enabled = true
+
+[node name="LineEdit" type="LineEdit" parent="VBoxContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+placeholder_text = "Type commands here"
+keep_editing_on_text_submit = true
diff --git a/src/modules/gis_hub/module_gis_hub.gd b/src/modules/gis_hub/module_gis_hub.gd
index c18eeb7..0a8397f 100644
--- a/src/modules/gis_hub/module_gis_hub.gd
+++ b/src/modules/gis_hub/module_gis_hub.gd
@@ -28,6 +28,7 @@ var _file_dialog: FileDialog = null
@onready var status_label: Label = %StatusLabel
@onready var connect_button: Button = %ConnectButton
@onready var bandwidth_watcher: GISHubBandwidthWatcher = %BandwidthWatcher
+@onready var cli_foldable_container: ModuleGISHubCLI = %CLIFoldableContainer
@onready var insim_settings: InsimSettings = %InSimSettings
@onready var open_modules_directory_button: Button = %OpenModulesDirectoryButton
@onready var open_data_directory_button: Button = %OpenDataDirectoryButton
@@ -62,18 +63,9 @@ func _ready_module() -> void:
func() -> void: insim_settings_update_requested.emit()
)
_connect = options_foldable_container.options_changed.connect(_on_options_changed)
- _connect = module_manager_widget.disable_modules_requested.connect(
- func(modules: Array[String]) -> void:
- disable_modules_requested.emit(modules)
- )
- _connect = module_manager_widget.enable_modules_requested.connect(
- func(modules: Array[String]) -> void:
- enable_modules_requested.emit(modules)
- )
- _connect = module_manager_widget.reload_modules_requested.connect(
- func(modules: Array[String]) -> void:
- reload_modules_requested.emit(modules)
- )
+ _connect = module_manager_widget.disable_modules_requested.connect(request_disable_modules)
+ _connect = module_manager_widget.enable_modules_requested.connect(request_enable_modules)
+ _connect = module_manager_widget.reload_modules_requested.connect(request_reload_modules)
_connect = connect_button.pressed.connect(_on_insim_button_pressed)
_connect = open_modules_directory_button.pressed.connect(_on_open_modules_directory_pressed)
_connect = open_data_directory_button.pressed.connect(_on_open_data_directory_pressed)
@@ -183,10 +175,26 @@ func receive_sorted_modules(modules: Array[String]) -> void:
_sorted_modules_received.emit(modules)
+func request_disable_modules(modules: Array[String]) -> void:
+ disable_modules_requested.emit(modules)
+
+
+func request_enable_modules(modules: Array[String]) -> void:
+ enable_modules_requested.emit(modules)
+
+
+func request_reload_modules(modules: Array[String]) -> void:
+ reload_modules_requested.emit(modules)
+
+
func set_insim_flags(flags: int) -> void:
insim_settings.set_flags(flags)
+func write_to_cli(message: String) -> void:
+ cli_foldable_container.write_message(message)
+
+
func _load_config_file(config_name := LAST_CONFIG) -> bool:
var config_path := get_data_directory().path_join(GIS_CONFIG_DIRECTORY).path_join(config_name)
var new_config: GISConfig = null
diff --git a/src/modules/gis_hub/module_gis_hub.tscn b/src/modules/gis_hub/module_gis_hub.tscn
index 14ad814..8ad1a7f 100644
--- a/src/modules/gis_hub/module_gis_hub.tscn
+++ b/src/modules/gis_hub/module_gis_hub.tscn
@@ -1,9 +1,10 @@
-[gd_scene load_steps=6 format=3 uid="uid://cptvr7cxom6vw"]
+[gd_scene load_steps=7 format=3 uid="uid://cptvr7cxom6vw"]
[ext_resource type="Script" uid="uid://buo7qpshly3c4" path="res://src/modules/gis_hub/module_gis_hub.gd" id="1_y06oj"]
[ext_resource type="PackedScene" uid="uid://bcp44jd13vsse" path="res://src/modules/gis_hub/bandwidth_watcher/bandwidth_watcher.tscn" id="2_2n03v"]
[ext_resource type="PackedScene" uid="uid://bbau6gj6ay2ix" path="res://src/modules/gis_hub/insim_settings.tscn" id="2_lex3d"]
[ext_resource type="PackedScene" uid="uid://d1rox3mqu7cgg" path="res://src/modules/gis_hub/module_manager_widget.tscn" id="3_lex3d"]
+[ext_resource type="PackedScene" uid="uid://chwlwurv6b4qe" path="res://src/modules/gis_hub/gis_hub_cli.tscn" id="3_lwknj"]
[ext_resource type="PackedScene" uid="uid://rl7sgyep716t" path="res://src/modules/gis_hub/gis_hub_options.tscn" id="3_oqwdt"]
[node name="GIS Hub" type="MarginContainer"]
@@ -56,6 +57,11 @@ text = "Connect"
unique_name_in_owner = true
layout_mode = 2
+[node name="CLIFoldableContainer" parent="VBoxContainer" instance=ExtResource("3_lwknj")]
+unique_name_in_owner = true
+layout_mode = 2
+folded = true
+
[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
size_flags_vertical = 3
diff --git a/src/modules/gis_module.gd b/src/modules/gis_module.gd
index b4e7d9f..fa1a735 100644
--- a/src/modules/gis_module.gd
+++ b/src/modules/gis_module.gd
@@ -1,6 +1,5 @@
@abstract class_name GISModule
extends MarginContainer
-
## Base class for GIS Hub modules. All modules inherit from it.
##
## GIS Hub modules are full-fledged InSim applications that can be enabled or disabled on the fly
@@ -49,6 +48,8 @@ extends MarginContainer
## print(packet.msg)
## [/codeblock]
+## Emitted when the module writes a [param message] to the CLI (see [method write_cli_message]).
+signal cli_message_posted(message: String)
## Emitted automatically when calling [method send_custom_data].
signal custom_data_sent(module_name: String, target_module: String, identifier: String, data: Array)
## Emitted when calling [method connect_insim] from the module, instead of
@@ -110,6 +111,8 @@ var _insim_requirements: GISModuleInSimRequirements = null
# Other modules this module "depends" on (soft dependencies, for communication with
# send_custom_data() and _on_module_sent_custom_data().
var _dependencies: Array[String] = []
+# The list of registered commands for CLI use.
+var _registered_commands: Array[GISHubCLICommand] = []
func _init() -> void:
@@ -124,6 +127,7 @@ func _init() -> void:
if not _insim_requirements:
_insim_requirements = GISModuleInSimRequirements.new()
config = _initialize_config()
+ _register_commands()
func _ready() -> void:
@@ -179,6 +183,35 @@ func _save_config() -> void:
pass
+## Override to define the behavior of commands registered with [method _register_commands].
+## This method is only called for commands that were registered. It is recommended
+## to define as many functions as you have registered commands, and parse the arguments
+## in those functions. You can refer to [GISHubCLIParsedCommand] for more details.
+## [codeblock]
+## func _execute_command(command: GISHubCLIParsedCommand) -> void:
+## match command.name:
+## "cmd1":
+## execute_cmd1(command)
+## "cmd2":
+## execute_cmd2(command)
+##
+## func execute_cmd1(command: GISHubCLIParsedCommand) -> void:
+## for arg in command.arguments:
+## print("arg: ", arg.name, ", value: ", arg.value)
+## [/codeblock]
+@warning_ignore("unused_parameter")
+func _execute_command(command: GISHubCLIParsedCommand) -> void:
+ pass
+
+
+## Override to register custom commands for use via the command line interface.
+## The intended implementation is to add a call to [method register_command]
+## for each of the commands you want to make available for your module.
+## For command execution, see [method _execute_command].
+func _register_commands() -> void:
+ pass
+
+
## This method is called when InSim connects to LFS, and should enable module features that are
## enabled only when InSim is connected (e.g. enable a loop that checks for specific packets).
## See also [method _on_insim_disconnected] for disabling features upon disconnection.
@@ -825,6 +858,42 @@ func disconnect_insim() -> void:
insim_disconnect_requested.emit()
+## Executes the given [param command]. This function should not be called manually,
+## if you need to execute custom commands in your module, implement
+## [method _execute_command] instead.
+func execute_command(command: GISHubCLIParsedCommand) -> void:
+ if command.name == "module" and command.subcommand == "command":
+ if command.arguments.size() < 2:
+ command.name = "list"
+ else:
+ command.name = command.arguments[1].value
+ if command.name.begins_with("-"):
+ write_cli_error("%s: Invalid command name '%s'.\n" % [_module_name, command.name])
+ return
+ command.arguments.remove_at(1) # actual command
+ command.arguments.remove_at(0) # module name
+ if command.name == "list":
+ if _registered_commands.is_empty():
+ write_cli_message("%s: No commands are available.\n" % [_module_name])
+ else:
+ write_cli_message(
+ "%s commands:\n" % [_module_name] + "\n".join(get_commands()) + "\n"
+ )
+ else:
+ _execute_command(command)
+
+
+## Returns the list of custom commands available for this module. You can also display
+## those commands in the CLI with [code]module command MyModule list[/code].
+func get_commands() -> Array[String]:
+ var commands: Array[String] = []
+ for cmd in _registered_commands:
+ commands.append(
+ cmd.name + ("" if cmd.subcommand.is_empty() else " %s" % [cmd.subcommand])
+ )
+ return commands
+
+
## Returns the module's data directory, where you can store configuration files
## and custom data.
func get_data_directory() -> String:
@@ -940,6 +1009,24 @@ func load_config() -> void:
_load_config()
+## Registers a custom command for use via the CLI panel, or when running GIS Hub headless.
+## The intended way to call this method is the following:
+## [codeblock]
+## register_command(
+## GISHubCLICommand.create(
+## "my_command",
+## "optional_subcommand",
+## GISHubCLIArgument.create("", "", ["additional", "arguments"]),
+## )
+## )
+## [/codeblock]
+## Arguments can be named ([code]--arg-name[/code] or short name [code]-a[/code])
+## or stay unnamed, named arguments with no expected values act as options.
+## See [GISHubCLIArgument] for more details.
+func register_command(command: GISHubCLICommand) -> void:
+ _registered_commands.append(command)
+
+
## Saves the module's config to the data directory. You should call this whenever you need
## to save user options.
func save_config() -> void:
@@ -1019,6 +1106,18 @@ func set_module_version(major: int, minor: int, patch: int) -> void:
_module_version = (major << 16) + (minor << 8) + patch
+func write_cli_error(message: String) -> void:
+ write_cli_message("[color=red]" + message + "[/color]")
+
+
+func write_cli_message(message: String) -> void:
+ cli_message_posted.emit(message)
+
+
+func write_cli_warning(message: String) -> void:
+ write_cli_message("[color=goldenrod]" + message + "[/color]")
+
+
func _get_config_path() -> String:
return get_data_directory().path_join("config.tres")
diff --git a/test/cli/test_gis_hub_cli.gd b/test/cli/test_gis_hub_cli.gd
new file mode 100644
index 0000000..58b85d5
--- /dev/null
+++ b/test/cli/test_gis_hub_cli.gd
@@ -0,0 +1,101 @@
+extends GdUnitTestSuite
+
+# TestSuite generated from
+const __source = "res://src/cli/gis_hub_cli.gd"
+
+
+func test_replace_message_color(
+ message: String, expected: String, _test_parameters := [
+ ["simple message", "[color=ff0000]simple message[/color]"],
+ [
+ "[color=green]colored[/color] message",
+ "[color=green]colored[/color][color=ff0000] message[/color]",
+ ],
+ [
+ "[color=green]colored[/color] message",
+ "[color=green]colored[/color][color=ff0000] message[/color]",
+ ],
+ [
+ "[color=green]colored[color=yellow] message",
+ "[color=green]colored[/color][color=yellow] message[/color]",
+ ],
+ ]
+) -> void:
+ var cli := auto_free(GISHubCLI.new()) as GISHubCLI
+ var result := cli._replace_message_color(message, Color.RED)
+ var _test: GdUnitAssert = assert_str(result).is_equal(expected)
+
+
+func test_parse_command(
+ command: String, parsed: Array[String], _test_parameters := [
+ ["module list", ["module", "list"]],
+ ["module enable MyModule", ["module", "enable", "MyModule"]],
+ ["module enable \"MyModule\"", ["module", "enable", "MyModule"]],
+ ],
+) -> void:
+ var parsed_command := GISHubCLIParsedCommand.new(command)
+ var args: Array[String] = [parsed_command.name]
+ for arg in parsed_command.arguments:
+ if arg.type == arg.Type.ARGUMENT_UNNAMED:
+ args.append(arg.value)
+ else:
+ args.append(" ".join([arg.name, arg.value]))
+ var _test: GdUnitAssert = assert_array(args).is_equal(parsed)
+
+
+func test_parse_command_argument_with_spaces(
+ command: String, parsed: Array[String], _test_parameters := [
+ ["module enable My\\ Module", ["module", "enable", "My Module"]],
+ ["module enable \"My Module\"", ["module", "enable", "My Module"]],
+ ["module enable \"My\\ Module\"", ["module", "enable", "My Module"]],
+ [r'module reload "My\ very\ long\ Module"', ["module", "reload", "My very long Module"]],
+ ],
+) -> void:
+ var parsed_command := GISHubCLIParsedCommand.new(command)
+ var args: Array[String] = [parsed_command.name]
+ for arg in parsed_command.arguments:
+ if arg.type == arg.Type.ARGUMENT_UNNAMED:
+ args.append(arg.value)
+ else:
+ args.append(" ".join([arg.name, arg.value]))
+ var _test: GdUnitAssert = assert_array(args).is_equal(parsed)
+
+
+func test_parse_command_with_named_arguments(
+ command: String, parsed: Array[String], _test_parameters := [
+ [
+ "connect 127.0.0.1:29999 --ISF_MSO_COLS",
+ ["connect", "127.0.0.1:29999", "--ISF_MSO_COLS"],
+ ],
+ [
+ "connect 127.0.0.1:29999 --flags 2052",
+ ["connect", "127.0.0.1:29999", "--flags 2052"],
+ ],
+ [
+ "connect --ISF_MSO_COLS 127.0.0.1:29999 -f 2052",
+ ["connect", "--ISF_MSO_COLS", "127.0.0.1:29999", "-f 2052"],
+ ],
+ ],
+) -> void:
+ var known_command := GISHubCLICommand.create(
+ "connect",
+ "",
+ GISHubCLIOption.create("ISF_MSO_COLS", ""),
+ GISHubCLIArgument.create("flags", "f"),
+ )
+ var parsed_command := GISHubCLIParsedCommand.new(command, known_command)
+ var args: Array[String] = [parsed_command.name]
+ for arg in parsed_command.arguments:
+ if arg.type == arg.Type.ARGUMENT_UNNAMED:
+ args.append(arg.value)
+ else:
+ var prefix := (
+ "--" if arg.type in [arg.Type.OPTION_FULL, arg.Type.ARGUMENT_FULL]
+ else "-" if arg.type in [arg.Type.OPTION_SHORT, arg.Type.ARGUMENT_SHORT]
+ else ""
+ )
+ args.append(
+ (prefix + " ".join([arg.name, arg.value])) if known_command.has_argument(arg.name)
+ else (prefix + arg.name)
+ )
+ var _test: GdUnitAssert = assert_array(args).is_equal(parsed)
diff --git a/test/cli/test_gis_hub_cli.gd.uid b/test/cli/test_gis_hub_cli.gd.uid
new file mode 100644
index 0000000..0b19337
--- /dev/null
+++ b/test/cli/test_gis_hub_cli.gd.uid
@@ -0,0 +1 @@
+uid://b3qusf1b7sujp
diff --git a/website/docs/guides/module_development/advanced_modules/advanced_modules.md b/website/docs/guides/module_development/advanced_modules/advanced_modules.md
index ef8689c..53a2ade 100644
--- a/website/docs/guides/module_development/advanced_modules/advanced_modules.md
+++ b/website/docs/guides/module_development/advanced_modules/advanced_modules.md
@@ -72,6 +72,8 @@ a quick explanation for all of them:
the saved configuration file.
- :class_ref[_save_config()]{target="GISModule#private_method__save_config"}: Used to save the module's current
state to a configuration file.
+- :class_ref[_register_commands()]{target="GISModule#private_method__register_commands"}: Used to register
+ custom commands that the module can execute when called from the CLI.
- :class_ref[_on_insim_connection_attempt()]{target="GISModule#private_method__on_insim_connection_attempt"}:
Used to execute code when the **Connect** button is pressed, or on auto-connect if enabled. You should
generally use _on_insim_connected() instead.
@@ -172,3 +174,97 @@ that module is not available, your own module won't get stuck waiting for someth
One possibility is to have a timeout when waiting for the data, e.g. using :gis[Awaiter].
:::
+
+## Module commands
+
+GIS Hub includes a CLI panel where you can type commands and read their output, and it can also be run entirely
+headless by passing the `--headless` option from a terminal. GIS Hub itself provides some commands
+to connect to InSim and manage modules, but it also allows you to define custom commands for your modules.
+
+In the following example, we will register a custom command that sends a message greeting players, as specified
+to the command by its arguments.
+
+```gdscript
+func _register_commands() -> void:
+ register_command(GISHubCLICommand.create("greet"))
+
+
+func _execute_command(command: GISHubCLIParsedCommand) -> void:
+ match command.name:
+ "greet":
+ var message := "Greetings"
+ for arg in command.arguments:
+ message += ", " + arg.value
+ message += "!"
+ send_message(message)
+```
+
+If we type the command in the CLI panel (or in the terminal while running GIS Hub headless), we get the following:
+```text
+module command MyModule greet
+module command MyModule greet "Person A"
+module command MyModule greet "Person A" "Person B"
+```
+> Greetings!
+> Greetings, Person A!
+> Greetings, Person A, Person B!
+
+We can also specify named options and arguments that restrict the values they can take. For instance, we can add
+an option to greet each person separately:
+```gdscript
+func _register_commands() -> void:
+ register_command(GISHubCLICommand.create(
+ "greet",
+ "",
+ GISHubCLIArgument.create("individual-greetings")
+ ))
+
+
+func _execute_command(command: GISHubCLIParsedCommand) -> void:
+ match command.name:
+ "greet":
+ var separate_greetings := false
+ for arg in command.arguments:
+ if arg.name == "individual-greetings":
+ separate_greetings = true
+ break
+ if separate_greetings:
+ for arg in command.arguments:
+ if arg.type != arg.Type.ARGUMENT_UNNAMED:
+ continue
+ send_message("Greetings, %s!" % [arg.value])
+ else:
+ var message := "Greetings"
+ for arg in command.arguments:
+ if arg.type != arg.Type.ARGUMENT_UNNAMED:
+ continue
+ message += ", " + arg.value
+ message += "!"
+ send_message(message)
+```
+
+The result is the following:
+```text
+module command MyModule greet --individual-greetings "Person A" "Person B"
+```
+> Greetings, Person A!
+> Greetings, Person B!
+
+:::info
+
+The :class_ref[GISHubCLIParsedCommand] `arguments` are parsed using registered commands to check for expected arguments.
+As a result, `--options` only have a `name`, `--named arguments` have a `name` and a `value`, and `unnamed_args`
+only have a `value`. If we register a command with those, and type the following in the command line:
+```text
+module command MyModule my_command --named arguments --options unnamed_args
+```
+the resulting :class_ref[GISHubCLIParsedCommand] will have 3 arguments:
+* named argument `named` with value `arguments`
+* option `options`
+* unnamed argument `unnamed_args`
+
+If you need or want to parse the original command as a string, you can retrieve it with str(command),
+but there should be no advantage to doing that, since the hyphens for options and named arguments are already
+taken care of.
+
+:::
--
GitLab