diff --git a/rmfcli/colcon.pkg b/rmfcli/colcon.pkg
new file mode 100644
index 0000000..a860870
--- /dev/null
+++ b/rmfcli/colcon.pkg
@@ -0,0 +1,7 @@
+{
+ "hooks": [
+ "share/rmfcli/environment/rmf-argcomplete.bash",
+ "share/rmfcli/environment/rmf-argcomplete.fish",
+ "share/rmfcli/environment/rmf-argcomplete.zsh"
+ ]
+}
diff --git a/rmfcli/completion/rmf-argcomplete.bash b/rmfcli/completion/rmf-argcomplete.bash
new file mode 100644
index 0000000..ec622ff
--- /dev/null
+++ b/rmfcli/completion/rmf-argcomplete.bash
@@ -0,0 +1,19 @@
+# Copyright 2017-2026 Open Source Robotics Foundation, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+if type register-python-argcomplete3 > /dev/null 2>&1; then
+ eval "$(register-python-argcomplete3 rmf)"
+elif type register-python-argcomplete > /dev/null 2>&1; then
+ eval "$(register-python-argcomplete rmf)"
+fi
diff --git a/rmfcli/completion/rmf-argcomplete.fish b/rmfcli/completion/rmf-argcomplete.fish
new file mode 100644
index 0000000..7947aa2
--- /dev/null
+++ b/rmfcli/completion/rmf-argcomplete.fish
@@ -0,0 +1,20 @@
+# Copyright 2017-2026 Open Source Robotics Foundation, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Register rmf tab-completion for fish shell via argcomplete.
+if type -q register-python-argcomplete
+ register-python-argcomplete --shell fish rmf | source
+else if type -q register-python-argcomplete3
+ register-python-argcomplete3 --shell fish rmf | source
+end
diff --git a/rmfcli/completion/rmf-argcomplete.zsh b/rmfcli/completion/rmf-argcomplete.zsh
new file mode 100644
index 0000000..217f13c
--- /dev/null
+++ b/rmfcli/completion/rmf-argcomplete.zsh
@@ -0,0 +1,25 @@
+# Copyright 2017-2018 Open Source Robotics Foundation, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+if (( ! ${+_comps} )); then
+ autoload -U +X compinit && compinit
+fi
+autoload -U +X bashcompinit && bashcompinit
+
+# Get this scripts directory
+__rmfcli_completion_dir=${0:a:h}
+# Just source the bash version, it works in zsh too
+source "$__rmfcli_completion_dir/rmf-argcomplete.bash"
+# Cleanup
+unset __rmfcli_completion_dir
diff --git a/rmfcli/package.xml b/rmfcli/package.xml
new file mode 100644
index 0000000..7201df4
--- /dev/null
+++ b/rmfcli/package.xml
@@ -0,0 +1,27 @@
+
+
+
+ rmfcli
+ 0.0.0
+
+ RMF command line tools.
+
+ Chen Bainian
+ Apache License 2.0
+
+ Chen Bainian
+
+ python3-argcomplete
+ rclpy
+ rmf_prototype_msgs
+
+ ament_copyright
+ ament_flake8
+ ament_pep257
+ ament_xmllint
+ python3-pytest
+
+
+ ament_python
+
+
diff --git a/rmfcli/resource/package.dsv b/rmfcli/resource/package.dsv
new file mode 100644
index 0000000..d78e835
--- /dev/null
+++ b/rmfcli/resource/package.dsv
@@ -0,0 +1,3 @@
+source;share/rmfcli/environment/rmf-argcomplete.bash
+source;share/rmfcli/environment/rmf-argcomplete.fish
+source;share/rmfcli/environment/rmf-argcomplete.zsh
diff --git a/rmfcli/resource/rmfcli b/rmfcli/resource/rmfcli
new file mode 100644
index 0000000..e69de29
diff --git a/rmfcli/rmfcli/__init__.py b/rmfcli/rmfcli/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/rmfcli/rmfcli/cli.py b/rmfcli/rmfcli/cli.py
new file mode 100644
index 0000000..8958105
--- /dev/null
+++ b/rmfcli/rmfcli/cli.py
@@ -0,0 +1,61 @@
+# Copyright 2026 Chen Bainian
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from argparse import ArgumentParser, RawDescriptionHelpFormatter
+from signal import SIGINT
+from typing import Any, Optional
+
+from .command import extensions as command_extensions
+from .extensions import ExtensionBase
+from .extensions.utils import add_subparsers
+
+
+def main(
+ *, script_name: str = "rmf", argv: Any = None, description: Optional[str] = None
+) -> int | str | None:
+ if description is None:
+ description = f"{script_name} is an extensible command-line tool for RMF"
+
+ # top level parser
+ parser = ArgumentParser(description=description, formatter_class=RawDescriptionHelpFormatter)
+ extension_key = "_command"
+ add_subparsers(
+ parser, script_name, extension_key, command_extensions, required=False, argv=argv
+ )
+
+ # register argcomplete hook if available
+ try:
+ from argcomplete import autocomplete
+ except ImportError:
+ pass
+ else:
+ autocomplete(parser, exclude=["-h", "--help"], always_complete_options=False)
+
+ args = parser.parse_args(args=argv)
+
+ extension: Optional[ExtensionBase] = getattr(args, extension_key, None)
+
+ # handle the case that no command was passed
+ if extension is None:
+ parser.print_help()
+ return 0
+
+ # call the main method of the extension
+ try:
+ rc = extension.main(parser=parser, args=args)
+ except KeyboardInterrupt:
+ rc = SIGINT
+ except RuntimeError as e:
+ rc = str(e)
+ return rc
diff --git a/rmfcli/rmfcli/command/__init__.py b/rmfcli/rmfcli/command/__init__.py
new file mode 100644
index 0000000..b656c14
--- /dev/null
+++ b/rmfcli/rmfcli/command/__init__.py
@@ -0,0 +1,22 @@
+# Copyright 2026 Chen Bainian
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from collections import OrderedDict
+
+from rmfcli.extensions import ExtensionBase
+
+from .destination import DestinationCommand
+
+extensions: OrderedDict[str, ExtensionBase] = OrderedDict()
+extensions["destination"] = DestinationCommand()
diff --git a/rmfcli/rmfcli/command/destination.py b/rmfcli/rmfcli/command/destination.py
new file mode 100644
index 0000000..74ede88
--- /dev/null
+++ b/rmfcli/rmfcli/command/destination.py
@@ -0,0 +1,40 @@
+# Copyright 2026 Chen Bainian
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from argparse import ArgumentParser
+from typing import Any
+
+from rmfcli.extensions import CommandExtension, VerbExtension
+from rmfcli.extensions.utils import add_subparsers
+from rmfcli.verb.destination import extensions as verb_extensions
+
+
+class DestinationCommand(CommandExtension):
+ """Run Destination CLI sub-commands"""
+
+ def add_arguments(self, parser: ArgumentParser, cli_name: str, *, argv: Any = None) -> None:
+ self._subparser = parser
+ add_subparsers(parser, cli_name, "_verb", verb_extensions, required=False)
+
+ def main(self, *, parser: ArgumentParser, args: Any) -> None | int:
+ if not hasattr(args, "_verb"):
+ # in case no verb was passed
+ self._subparser.print_help()
+ return 0
+
+ extension: VerbExtension = args._verb
+
+ # call the verb's main method
+ rc: None | int = extension.main(parser=parser, args=args)
+ return rc
diff --git a/rmfcli/rmfcli/extensions/__init__.py b/rmfcli/rmfcli/extensions/__init__.py
new file mode 100644
index 0000000..ecc336b
--- /dev/null
+++ b/rmfcli/rmfcli/extensions/__init__.py
@@ -0,0 +1,17 @@
+# Copyright 2026 Chen Bainian
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from .command_extension import CommandExtension
+from .extension_base import ExtensionBase
+from .verb_extension import VerbExtension
diff --git a/rmfcli/rmfcli/extensions/command_extension.py b/rmfcli/rmfcli/extensions/command_extension.py
new file mode 100644
index 0000000..a969d19
--- /dev/null
+++ b/rmfcli/rmfcli/extensions/command_extension.py
@@ -0,0 +1,8 @@
+from .extension_base import ExtensionBase
+
+
+class CommandExtension(ExtensionBase):
+ NAME = None
+
+ def __init__(self) -> None:
+ super(CommandExtension, self).__init__()
diff --git a/rmfcli/rmfcli/extensions/extension_base.py b/rmfcli/rmfcli/extensions/extension_base.py
new file mode 100644
index 0000000..0f89b55
--- /dev/null
+++ b/rmfcli/rmfcli/extensions/extension_base.py
@@ -0,0 +1,43 @@
+# Copyright 2026 Chen Bainian
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.from argparse import ArgumentParser
+
+from argparse import ArgumentParser
+from typing import Any
+
+
+class ExtensionBase:
+ """
+ The extension point for 'command' extensions.
+
+ The following properties must be defined:
+ * `NAME` (will be set to the entry point name)
+
+ The following methods must be defined:
+ * `main`
+
+ The following methods can be defined:
+ * `add_arguments`
+ """
+
+ NAME = None
+
+ def __init__(self) -> None:
+ super(ExtensionBase, self).__init__()
+
+ def add_arguments(self, parser: ArgumentParser, cli_name: str, *, argv: Any = None) -> None:
+ pass
+
+ def main(self, *, parser: ArgumentParser, args: Any) -> int | None:
+ raise NotImplementedError()
+ pass
diff --git a/rmfcli/rmfcli/extensions/utils.py b/rmfcli/rmfcli/extensions/utils.py
new file mode 100644
index 0000000..c153668
--- /dev/null
+++ b/rmfcli/rmfcli/extensions/utils.py
@@ -0,0 +1,49 @@
+from argparse import ArgumentParser, RawDescriptionHelpFormatter, _SubParsersAction
+from collections import OrderedDict
+from typing import Any, List
+
+from .extension_base import ExtensionBase
+
+
+def get_first_line_doc(any_type: Any) -> str:
+ if not any_type.__doc__:
+ return ""
+ lines: List[str] = any_type.__doc__.splitlines()
+ if not lines:
+ return ""
+ if lines[0]:
+ line = lines[0]
+ elif len(lines) > 1:
+ line = lines[1]
+ return line.strip().rstrip(".")
+
+
+def add_subparsers(
+ parser: ArgumentParser,
+ cli_name: str,
+ dest: str,
+ extensions: OrderedDict[str, ExtensionBase],
+ required: bool = True,
+ argv: Any = None,
+) -> _SubParsersAction:
+ # Generate description
+ description = ""
+ max_length = max(len(name) for name in extensions.keys())
+ for name, extension in extensions.items():
+ description += "%s %s\n" % (name.ljust(max_length), get_first_line_doc(extension))
+
+ # Create subparser
+ subparsers = parser.add_subparsers(
+ title="Commands",
+ description=description,
+ metavar=f"Call `{cli_name} -h` for more detailed usage.",
+ )
+
+ subparsers.dest = " " + dest.lstrip("_")
+ subparsers.required = required
+ for name, extension in extensions.items():
+ command_parser = subparsers.add_parser(name, formatter_class=RawDescriptionHelpFormatter)
+ extension.add_arguments(command_parser, name, argv=argv)
+ command_parser.set_defaults(**{dest: extension})
+
+ return subparsers
diff --git a/rmfcli/rmfcli/extensions/verb_extension.py b/rmfcli/rmfcli/extensions/verb_extension.py
new file mode 100644
index 0000000..636b8d0
--- /dev/null
+++ b/rmfcli/rmfcli/extensions/verb_extension.py
@@ -0,0 +1,29 @@
+# Copyright 2026 Chen Bainian
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from argparse import ArgumentParser
+from typing import Any
+
+from .extension_base import ExtensionBase
+
+
+class VerbExtension(ExtensionBase):
+ NAME = None
+
+ def __init__(self) -> None:
+ super(VerbExtension, self).__init__()
+
+ def main(self, *, parser: ArgumentParser, args: Any) -> int | None:
+ raise NotImplementedError()
+ pass
diff --git a/rmfcli/rmfcli/py.typed b/rmfcli/rmfcli/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/rmfcli/rmfcli/verb/__init__.py b/rmfcli/rmfcli/verb/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/rmfcli/rmfcli/verb/destination/__init__.py b/rmfcli/rmfcli/verb/destination/__init__.py
new file mode 100644
index 0000000..395b3fe
--- /dev/null
+++ b/rmfcli/rmfcli/verb/destination/__init__.py
@@ -0,0 +1,10 @@
+from typing import OrderedDict
+
+from rmfcli.extensions import ExtensionBase
+
+from .send import SendVerb
+from .dummy import DummyVerb
+
+extensions: OrderedDict[str, ExtensionBase] = OrderedDict()
+extensions["send"] = SendVerb()
+extensions["dummy"] = DummyVerb()
diff --git a/rmfcli/rmfcli/verb/destination/dummy.py b/rmfcli/rmfcli/verb/destination/dummy.py
new file mode 100644
index 0000000..4b56bdd
--- /dev/null
+++ b/rmfcli/rmfcli/verb/destination/dummy.py
@@ -0,0 +1,27 @@
+# Copyright 2026 Chen Bainian
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from argparse import ArgumentParser
+from typing import Any
+
+from rmfcli.extensions import VerbExtension
+
+class DummyVerb(VerbExtension):
+ """Run dummy print script (example)"""
+
+ def add_arguments(self, parser: ArgumentParser, cli_name: str, *, argv: Any = None) -> None:
+ parser.add_argument("message", type=str, help="Message to print")
+
+ def main(self, *, parser: ArgumentParser, args: Any) -> int | None:
+ print(args.message)
diff --git a/rmfcli/rmfcli/verb/destination/send.py b/rmfcli/rmfcli/verb/destination/send.py
new file mode 100644
index 0000000..ccfd3c7
--- /dev/null
+++ b/rmfcli/rmfcli/verb/destination/send.py
@@ -0,0 +1,28 @@
+# Copyright 2026 Chen Bainian
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from argparse import ArgumentParser
+from collections import OrderedDict
+from typing import Any
+
+from rmfcli.extensions import VerbExtension
+
+class SendVerb(VerbExtension):
+ """Send Destination"""
+
+ def add_arguments(self, parser: ArgumentParser, cli_name: str, *, argv: Any = None) -> None:
+ pass
+
+ def main(self, *, parser: ArgumentParser, args: Any) -> int | None:
+ pass
diff --git a/rmfcli/setup.py b/rmfcli/setup.py
new file mode 100644
index 0000000..3645751
--- /dev/null
+++ b/rmfcli/setup.py
@@ -0,0 +1,50 @@
+from setuptools import find_packages
+from setuptools import setup
+
+setup(
+ name='rmfcli',
+ version='0.0.0',
+ packages=find_packages(exclude=['test']),
+ extras_require={
+ 'completion': ['argcomplete'],
+ 'test': [
+ 'pytest',
+ ],
+ },
+ data_files=[
+ ('share/ament_index/resource_index/packages', [
+ 'resource/rmfcli',
+ ]),
+ ('share/rmfcli', [
+ 'package.xml',
+ 'resource/package.dsv',
+ ]),
+ ('share/rmfcli/environment', [
+ 'completion/rmf-argcomplete.bash',
+ 'completion/rmf-argcomplete.fish',
+ 'completion/rmf-argcomplete.zsh',
+ ]),
+ ],
+ package_data={'': ['py.typed']},
+ zip_safe=False,
+ author='Chen Bainian',
+ author_email='chenbn@a-star.edu.sg',
+ maintainer='Chen Bainian',
+ maintainer_email='chenbn@a-star.edu.sg',
+ keywords=[],
+ classifiers=[
+ 'Environment :: Console',
+ 'Intended Audience :: Developers',
+ 'Programming Language :: Python',
+ ],
+ description='RMF command line tools.',
+ long_description="""\
+The framework provides a single command line script which can be extended with
+commands and verbs.""",
+ license='Apache License, Version 2.0',
+ entry_points={
+ 'console_scripts': [
+ 'rmf = rmfcli.cli:main',
+ ],
+ }
+)