diff --git a/maubot/cli/__init__.py b/maubot/cli/__init__.py
new file mode 100644
index 0000000..d25736b
--- /dev/null
+++ b/maubot/cli/__init__.py
@@ -0,0 +1,2 @@
+from . import commands
+from .base import app
diff --git a/maubot/cli/__main__.py b/maubot/cli/__main__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/maubot/cli/base.py b/maubot/cli/base.py
new file mode 100644
index 0000000..2201c30
--- /dev/null
+++ b/maubot/cli/base.py
@@ -0,0 +1,23 @@
+# maubot - A plugin-based Matrix bot system.
+# Copyright (C) 2018 Tulir Asokan
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+import click
+
+from .config import load_config
+
+
+@click.group()
+def app() -> None:
+ load_config()
diff --git a/maubot/cli/commands/__init__.py b/maubot/cli/commands/__init__.py
new file mode 100644
index 0000000..e94091c
--- /dev/null
+++ b/maubot/cli/commands/__init__.py
@@ -0,0 +1 @@
+from . import upload, build, login, init
diff --git a/maubot/cli/commands/build.py b/maubot/cli/commands/build.py
new file mode 100644
index 0000000..b820e2c
--- /dev/null
+++ b/maubot/cli/commands/build.py
@@ -0,0 +1,31 @@
+# maubot - A plugin-based Matrix bot system.
+# Copyright (C) 2018 Tulir Asokan
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+import click
+import os
+
+from ..base import app
+from ..util import type_path
+
+
+@app.command(short_help="Build a maubot plugin",
+ help="Build a maubot plugin. First parameter is the path to root of the plugin "
+ "to build. You can also use --output to specify output file.")
+@click.argument("path", default=".")
+@click.option("-o", "--output", help="Path to output built plugin to", type=type_path)
+@click.option("-u", "--upload", help="Upload plugin to main server after building", is_flag=True,
+ default=False)
+def build(path: str, output: str, upload: bool) -> None:
+ pass
diff --git a/maubot/cli/commands/init.py b/maubot/cli/commands/init.py
new file mode 100644
index 0000000..d2bb633
--- /dev/null
+++ b/maubot/cli/commands/init.py
@@ -0,0 +1,34 @@
+# maubot - A plugin-based Matrix bot system.
+# Copyright (C) 2018 Tulir Asokan
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+import click
+import os
+
+from ..base import app
+from ..util import type_path
+
+
+@app.command(help="Initialize a new maubot plugin")
+@click.option("-n", "--name", help="The name of the project", default=os.path.basename(os.getcwd()),
+ prompt=True, show_default="directory name")
+@click.option("-i", "--id", help="The maubot plugin ID (Java package name format)", prompt=True)
+@click.option("-v", "--version", help="Initial version for project", default="0.1.0",
+ show_default=True)
+@click.option("-l", "--license", help="The SPDX license identifier of the license for the project",
+ prompt=True, default="AGPL-3.0-or-later")
+@click.option("-c", "--config", help="Include a config in the plugin stub", is_flag=True,
+ default=False)
+def init(name: str, id: str, version: str, license: str, config: bool) -> None:
+ pass
diff --git a/maubot/cli/commands/login.py b/maubot/cli/commands/login.py
new file mode 100644
index 0000000..9f91bc1
--- /dev/null
+++ b/maubot/cli/commands/login.py
@@ -0,0 +1,48 @@
+# maubot - A plugin-based Matrix bot system.
+# Copyright (C) 2018 Tulir Asokan
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+from urllib.request import urlopen
+from urllib.error import HTTPError
+import click
+import json
+import os
+
+from colorama import Fore, Style
+
+from maubot.cli.base import app
+from maubot.cli.config import save_config, config
+
+
+@app.command(help="Log in to a Maubot instance")
+@click.argument("server", required=True, default="http://localhost:29316")
+@click.option("-u", "--username", help="The username of your account", prompt=True,
+ default=lambda: os.environ.get('USER', ''), show_default="current user")
+@click.password_option("-p", "--password", help="The password to your account", required=True,
+ confirmation_prompt=False)
+def login(server, username, password) -> None:
+ data = {
+ "username": username,
+ "password": password,
+ }
+ try:
+ with urlopen(f"{server}/_matrix/maubot/v1/auth/login",
+ data=json.dumps(data).encode("utf-8")) as resp_data:
+ resp = json.load(resp_data)
+ config["servers"][server] = resp["token"]
+ save_config()
+ print(Fore.GREEN, "Logged in successfully")
+ except HTTPError as e:
+ if e.code == 401:
+ print(Fore.RED + "Invalid username or password" + Style.RESET_ALL)
diff --git a/maubot/cli/commands/upload.py b/maubot/cli/commands/upload.py
new file mode 100644
index 0000000..c30a7a2
--- /dev/null
+++ b/maubot/cli/commands/upload.py
@@ -0,0 +1,25 @@
+# maubot - A plugin-based Matrix bot system.
+# Copyright (C) 2018 Tulir Asokan
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+import click
+import os
+
+from maubot.cli.base import app
+
+
+@app.command(help="Upload a maubot plugin")
+@click.option("-s", "--server", help="The maubot instance to upload the plugin to")
+def upload(server: str) -> None:
+ pass
diff --git a/maubot/cli/config.py b/maubot/cli/config.py
new file mode 100644
index 0000000..fba278a
--- /dev/null
+++ b/maubot/cli/config.py
@@ -0,0 +1,36 @@
+# maubot - A plugin-based Matrix bot system.
+# Copyright (C) 2018 Tulir Asokan
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+import json
+import os
+
+config = {
+ "servers": {}
+}
+configdir = os.environ.get("XDG_CONFIG_HOME", os.path.join(os.environ.get("HOME"), ".config"))
+
+
+def save_config() -> None:
+ with open(f"{configdir}/maubot-cli.json", "w") as file:
+ json.dump(config, file)
+
+
+def load_config() -> None:
+ try:
+ with open(f"{configdir}/maubot-cli.json") as file:
+ loaded = json.load(file)
+ config["servers"] = loaded["servers"]
+ except FileNotFoundError:
+ pass
diff --git a/maubot/cli/template/config.yaml b/maubot/cli/template/config.yaml
new file mode 100644
index 0000000..bbeb6da
--- /dev/null
+++ b/maubot/cli/template/config.yaml
@@ -0,0 +1,6 @@
+example_1: Example value 1
+example_2:
+ list:
+ - foo
+ - bar
+ value: asd
diff --git a/maubot/cli/template/plugin-with-config.py.j2 b/maubot/cli/template/plugin-with-config.py.j2
new file mode 100644
index 0000000..18ca827
--- /dev/null
+++ b/maubot/cli/template/plugin-with-config.py.j2
@@ -0,0 +1,20 @@
+from maubot import Plugin
+from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper
+
+class Config(BaseProxyConfig):
+ def do_update(self, helper: ConfigUpdateHelper) -> None:
+ helper.copy("example_1")
+ helper.copy("example_2.list")
+ helper.copy("example_2.value")
+
+class {{ name }}:
+ async def start() -> None:
+ self.config.load_and_update()
+ self.log.debug("Loaded %s from config example 2", self.config["example_2.value"])
+
+ async def stop() -> None:
+ pass
+
+ @classmethod
+ def get_config_class(cls) -> Type[BaseProxyConfig]:
+ return Config
diff --git a/maubot/cli/template/plugin.py.j2 b/maubot/cli/template/plugin.py.j2
new file mode 100644
index 0000000..fc056ff
--- /dev/null
+++ b/maubot/cli/template/plugin.py.j2
@@ -0,0 +1,8 @@
+from maubot import Plugin
+
+class {{ name }}:
+ async def start() -> None:
+ pass
+
+ async def stop() -> None:
+ pass
diff --git a/maubot/cli/util/__init__.py b/maubot/cli/util/__init__.py
new file mode 100644
index 0000000..a5cbafb
--- /dev/null
+++ b/maubot/cli/util/__init__.py
@@ -0,0 +1 @@
+from .path import type_path
diff --git a/maubot/cli/util/path.py b/maubot/cli/util/path.py
new file mode 100644
index 0000000..75cae57
--- /dev/null
+++ b/maubot/cli/util/path.py
@@ -0,0 +1,14 @@
+import click
+import os
+
+
+def type_path(val: str) -> str:
+ val = os.path.abspath(val)
+ if os.path.exists(val):
+ return val
+ directory = os.path.dirname(val)
+ if not os.path.isdir(directory):
+ if os.path.exists(directory):
+ raise click.BadParameter(f"{directory} is not a directory")
+ raise click.BadParameter(f"{directory} does not exist")
+ return val
diff --git a/requirements.txt b/requirements.txt
index 4067af6..79baba1 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -7,3 +7,7 @@ ruamel.yaml
attrs
bcrypt
packaging
+
+click
+colorama
+jinja2
diff --git a/setup.py b/setup.py
index a8c79db..2dc5ae3 100644
--- a/setup.py
+++ b/setup.py
@@ -30,6 +30,10 @@ setuptools.setup(
"attrs>=18.1.0,<19",
"bcrypt>=3.1.4,<4",
"packaging>=10",
+
+ "click>=7,<8",
+ "colorama>=0.4,<0.5",
+ "jinja2>=2,<3",
],
classifiers=[
@@ -45,7 +49,7 @@ setuptools.setup(
entry_points="""
[console_scripts]
maubot=maubot.__main__:main
- mbp=maubot.cli.__main__:main
+ mbc=maubot.cli:app
""",
data_files=[
(".", ["example-config.yaml"]),