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