diff --git a/.github/workflows/end2end.yml b/.github/workflows/end2end.yml
index 3954a3ad7f..bb016ca5e7 100644
--- a/.github/workflows/end2end.yml
+++ b/.github/workflows/end2end.yml
@@ -64,3 +64,52 @@ jobs:
EB_ARGS='--filter-deps=binutils'
fi
sudo -u easybuild bash -l -c "source /tmp/eb_env; eb bzip2-1.0.8.eb --trace --robot ${EB_ARGS}"
+
+ - name: Ensure `python3 -m venv` can be used
+ if: startsWith(matrix.container, 'ubuntu')
+ shell: bash
+ run: |
+ apt update && apt install -y python3-venv
+
+ - name: Create a virtual environment and install EasyBuild with the click cli extra
+ shell: bash
+ run: |
+ export VENVDIR=/home/easybuild/venv
+ python3 -m venv $VENVDIR
+ source $VENVDIR/bin/activate
+ pip install --upgrade pip
+ pip install .[eb_click]
+ pip install $HOME/easybuild-easyblocks-develop
+ pip install $HOME/easybuild-easyconfigs-develop
+ chown -R easybuild $VENVDIR
+
+ - name: Run commands to check test environment using the click cli
+ shell: bash
+ run: |
+ export VENVDIR=/home/easybuild/venv
+ cmds=(
+ "whoami"
+ "pwd"
+ "env | sort"
+ "eb --help"
+ "eb --version"
+ "eb --help | grep '─ Options ─'"
+ "eb --show-system-info"
+ "eb --check-eb-deps"
+ "eb --show-config"
+ "eb -x bzip2-1.0.8.eb"
+ )
+ for cmd in "${cmds[@]}"; do
+ echo ">>> $cmd"
+ sudo -u easybuild bash -l -c "export EB_CLI_CLICK=1; source $VENVDIR/bin/activate; $cmd"
+ done
+
+ - name: End-to-end test of installing bzip2 with EasyBuild using the click cli
+ shell: bash
+ run: |
+ export VENVDIR=/home/easybuild/venv
+ EB_ARGS=''
+ if [[ "${{ matrix.container }}" == "fedora-41" ]] || [[ "${{ matrix.container }}" == "ubuntu-24.04" ]]; then
+ EB_ARGS='--filter-deps=binutils'
+ fi
+ sudo -u easybuild bash -l -c "export EB_CLI_CLICK=1; source $VENVDIR/bin/activate; eb bzip2-1.0.8.eb --rebuild --trace --robot ${EB_ARGS}"
diff --git a/easybuild/cli/__init__.py b/easybuild/cli/__init__.py
new file mode 100644
index 0000000000..cc14040833
--- /dev/null
+++ b/easybuild/cli/__init__.py
@@ -0,0 +1,59 @@
+# #
+# Copyright 2009-2026 Ghent University
+#
+# This file is part of EasyBuild,
+# originally created by the HPC team of Ghent University (http://ugent.be/hpc/en),
+# with support of Ghent University (http://ugent.be/hpc),
+# the Flemish Supercomputer Centre (VSC) (https://www.vscentrum.be),
+# Flemish Research Foundation (FWO) (http://www.fwo.be/en)
+# and the Department of Economy, Science and Innovation (EWI) (http://www.ewi-vlaanderen.be/en).
+#
+# https://github.com/easybuilders/easybuild
+#
+# EasyBuild is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation v2.
+#
+# EasyBuild 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with EasyBuild. If not, see .
+# #
+from easybuild.main import main_with_hooks
+
+try:
+ import click as original_click
+except ImportError:
+ def eb(*args, **kwargs):
+ """Placeholder function to inform the user that `click` is required."""
+ main_with_hooks()
+else:
+ try:
+ import rich_click as click
+ except ImportError:
+ import click
+
+ try:
+ from rich.traceback import install
+ except ImportError:
+ pass
+ else:
+ install(suppress=[
+ click, original_click
+ ])
+
+ from .options import EasyBuildCliOption, EasyconfigParam
+ from easybuild.tools.version import this_is_easybuild
+
+ @click.command()
+ @EasyBuildCliOption.apply_options
+ @click.argument('other_args', nargs=-1, type=EasyconfigParam(), required=False)
+ @click.version_option(version=this_is_easybuild(), message='%(version)s')
+ def eb(other_args):
+ """EasyBuild command line interface."""
+ # Really no need to re-build the arguments if we support the exact same syntax we can just let them pass
+ # through to optparse
+ main_with_hooks()
diff --git a/easybuild/cli/__main__.py b/easybuild/cli/__main__.py
new file mode 100644
index 0000000000..20976142b2
--- /dev/null
+++ b/easybuild/cli/__main__.py
@@ -0,0 +1,28 @@
+# #
+# Copyright 2009-2026 Ghent University
+#
+# This file is part of EasyBuild,
+# originally created by the HPC team of Ghent University (http://ugent.be/hpc/en),
+# with support of Ghent University (http://ugent.be/hpc),
+# the Flemish Supercomputer Centre (VSC) (https://www.vscentrum.be),
+# Flemish Research Foundation (FWO) (http://www.fwo.be/en)
+# and the Department of Economy, Science and Innovation (EWI) (http://www.ewi-vlaanderen.be/en).
+#
+# https://github.com/easybuilders/easybuild
+#
+# EasyBuild is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation v2.
+#
+# EasyBuild 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with EasyBuild. If not, see .
+# #
+from easybuild.cli import eb
+
+# Ensure Click to recognizes the program name as `eb` when invoked as `python -m easybuild.cli` or similar
+eb(prog_name='eb')
diff --git a/easybuild/cli/options/__init__.py b/easybuild/cli/options/__init__.py
new file mode 100644
index 0000000000..921968e198
--- /dev/null
+++ b/easybuild/cli/options/__init__.py
@@ -0,0 +1,337 @@
+# #
+# Copyright 2009-2026 Ghent University
+#
+# This file is part of EasyBuild,
+# originally created by the HPC team of Ghent University (http://ugent.be/hpc/en),
+# with support of Ghent University (http://ugent.be/hpc),
+# the Flemish Supercomputer Centre (VSC) (https://www.vscentrum.be),
+# Flemish Research Foundation (FWO) (http://www.fwo.be/en)
+# and the Department of Economy, Science and Innovation (EWI) (http://www.ewi-vlaanderen.be/en).
+#
+# https://github.com/easybuilders/easybuild
+#
+# EasyBuild is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation v2.
+#
+# EasyBuild 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 General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with EasyBuild. If not, see .
+# #
+import os
+import re
+
+from typing import Callable, Any, List, Dict
+from dataclasses import dataclass
+
+from click.shell_completion import CompletionItem
+from easybuild.tools.options import EasyBuildOptions, set_up_configuration
+from easybuild.tools.robot import search_easyconfigs
+
+
+opt_group = {}
+try:
+ import rich_click as click
+except ImportError:
+ import click
+else:
+ opt_group = click.rich_click.OPTION_GROUPS
+ opt_group.clear() # Clear existing groups to avoid conflicts
+
+
+KNOWN_FILEPATH_OPTS = [
+ 'hooks',
+ 'modules-footer',
+ 'modules-header',
+]
+
+KNOWN_DIRPATH_OPTS = [
+ 'locks-dir',
+
+ 'failed-install-build-dirs-path',
+ 'failed-install-logs-path',
+ 'installpath-data',
+ 'installpath-modules',
+ 'installpath-software',
+ 'prefix',
+ 'sourcepath-data',
+ 'testoutput',
+ 'tmp-logdir',
+ 'tmpdir',
+
+ 'buildpath',
+ 'containerpath',
+ 'installpath',
+ 'sourcepath',
+]
+
+
+class OptionExtracter(EasyBuildOptions):
+ def __init__(self, *args, **kwargs):
+ self._option_dicts = {}
+ super().__init__(*args, **kwargs)
+
+ def add_group_parser(self, opt_dict, descr, *args, prefix='', **kwargs):
+ super().add_group_parser(opt_dict, descr, *args, prefix=prefix, **kwargs)
+ self._option_dicts[descr[0]] = (prefix, opt_dict)
+
+
+extracter = OptionExtracter(go_args=[])
+
+
+class DelimitedPathList(click.Path):
+ """Custom Click parameter type for delimited lists."""
+ name = 'pathlist'
+
+ def __init__(self, *args, delimiter=',', **kwargs):
+ self.resolve_full = kwargs.setdefault('resolve_path', False)
+ super().__init__(*args, **kwargs)
+ self.delimiter = delimiter
+ name = self.name
+ self.name = f'[{name}[{self.delimiter}{name}]]'
+
+ def convert(self, value, param, ctx):
+ if isinstance(value, str):
+ res = value.split(self.delimiter)
+ elif isinstance(value, (list, tuple)):
+ res = value
+ elif value is False or value is None:
+ res = value
+ else:
+ raise click.BadParameter(f"Expected a comma-separated string, got {value}")
+ if self.resolve_full:
+ res = [os.path.abspath(v) for v in res]
+ return res
+
+ def shell_complete(self, ctx, param, incomplete):
+ others, last = ([None] + incomplete.rsplit(self.delimiter, 1))[-2:]
+ dir_path, prefix = os.path.split(last)
+ dir_path = dir_path or '.'
+ # logging.warning(f"Shell completion for delimited path list: dir_path={dir_path}, prefix={prefix}")
+ possibles = []
+ for path in os.listdir(dir_path):
+ if not path.startswith(prefix):
+ continue
+ full_path = os.path.join(dir_path, path)
+ if os.path.isdir(full_path):
+ if self.dir_okay:
+ possibles.append(full_path)
+ possibles.append(full_path + os.sep)
+ elif os.path.isfile(full_path):
+ if self.file_okay:
+ possibles.append(full_path)
+ if others is None:
+ start = ''
+ elif others == '':
+ start = self.delimiter
+ else:
+ start = f'{others}{self.delimiter}'
+ res = [CompletionItem(f"{start}{path}") for path in possibles]
+ # logging.warning(f"Shell completion for delimited path list: res={possibles}")
+ return res
+
+
+class DelimitedString(click.ParamType):
+ """Custom Click parameter type for delimited strings."""
+ def __init__(self, *args, delimiter=',', **kwargs):
+ super().__init__(*args, **kwargs)
+ self.delimiter = delimiter
+ self.name = f'[STR[{self.delimiter}STR]]'
+
+ def convert(self, value, param, ctx):
+ if isinstance(value, str):
+ res = value.split(self.delimiter)
+ elif isinstance(value, (list, tuple)):
+ res = value
+ elif value is False or value is None:
+ res = value
+ else:
+ raise click.BadParameter(f"Expected a string or a comma-separated string, got {value}")
+ return res
+
+ def shell_complete(self, ctx, param, incomplete):
+ last = incomplete.rsplit(self.delimiter, 1)[-1]
+ return super().shell_complete(ctx, param, last)
+
+
+class EasyconfigParam(click.ParamType):
+ """Custom Click parameter type for easyconfig parameters."""
+ name = 'easyconfig'
+
+ def shell_complete(self, ctx, param, incomplete):
+ incomplete = re.escape(incomplete)
+ set_up_configuration(args=["--ignore-index"], silent=True, reconfigure=True)
+ return [
+ CompletionItem(ec, help='') for ec in search_easyconfigs(
+ fr'^(?={incomplete}).*\.eb$',
+ filename_only=True
+ )
+ ]
+
+
+@dataclass
+class OptionData:
+ name: str
+ description: str
+ type: str
+ action: str
+ default: Any
+ group: str = None
+ short: str = None
+ meta: Dict = None
+ lst: List = None
+
+ def __post_init__(self):
+ if self.short is not None and not isinstance(self.short, str):
+ raise TypeError(f"Short option must be a string, got {type(self.short)}")
+ if self.meta is not None and not isinstance(self.meta, dict):
+ raise TypeError(f"Meta must be a dictionary, got {type(self.meta)}")
+ if self.lst is not None and not isinstance(self.lst, (list, tuple)):
+ raise TypeError(f"List must be a list or tuple, got {type(self.lst)}")
+
+ def to_click_option_dec(self):
+ """Convert OptionData to a click.Option."""
+ decl = f"--{self.name}"
+ other_decls = []
+ if self.short:
+ other_decls.insert(0, f"-{self.short}")
+
+ kwargs = {
+ 'help': self.description,
+ 'default': self.default,
+ 'is_flag': False,
+ 'show_default': True,
+ 'type': None
+ }
+
+ # Manually enforced FILE types
+ if self.name in KNOWN_FILEPATH_OPTS:
+ kwargs['type'] = click.Path(dir_okay=False, file_okay=True)
+ # Manually enforced DIRECTORY types
+ elif self.name in KNOWN_DIRPATH_OPTS:
+ kwargs['type'] = click.Path(dir_okay=True, file_okay=False)
+ # Convert options from easybuild.tools.options
+ elif self.type in ['strlist', 'strtuple']:
+ kwargs['type'] = DelimitedString(delimiter=',')
+ # kwargs['multiple'] = True
+ elif self.type in ['pathlist', 'pathtuple']:
+ kwargs['type'] = DelimitedPathList(delimiter=os.pathsep)
+ # kwargs['multiple'] = True
+ elif self.type in ['urllist', 'urltuple']:
+ kwargs['type'] = DelimitedString(delimiter='|')
+ # kwargs['multiple'] = True
+ elif self.type == 'choice':
+ if self.lst is None:
+ raise ValueError(f"Choice type requires a list of choices for option {self.name}")
+ kwargs['type'] = click.Choice(self.lst, case_sensitive=True)
+ # if self.default is not None:
+ # kwargs['is_flag'] = True
+ elif self.type in ['int', int]:
+ kwargs['type'] = click.INT
+ elif self.type in ['float', float]:
+ kwargs['type'] = click.FLOAT
+ elif self.type in ['str', str]:
+ kwargs['type'] = click.STRING
+ # If type is None assume type based on default value
+ elif self.type is None:
+ if self.default is False or self.default is True:
+ kwargs['is_flag'] = True
+ kwargs['type'] = click.BOOL
+ if self.default is True:
+ decl = f"--{self.name}/--disable-{self.name}"
+ elif isinstance(self.default, (list, tuple)):
+ kwargs['multiple'] = True
+ kwargs['type'] = click.STRING
+
+ # store_or_None implies that the option can be used as a flag with no value
+ if self.action == 'store_or_None':
+ kwargs['default'] = None
+ kwargs['flag_value'] = self.default
+ elif self.action == 'store_or_False':
+ kwargs['default'] = False
+ kwargs['flag_value'] = self.default
+
+ decls = other_decls + [decl]
+
+ return click.option(
+ *decls,
+ expose_value=False,
+ **kwargs
+ )
+
+
+class EasyBuildCliOption():
+ OPTIONS: List[OptionData] = []
+ OPTIONS_MAP: Dict[str, OptionData] = {}
+
+ @classmethod
+ def apply_options(cls, function: Callable) -> Callable:
+ """Decorator to apply EasyBuild options to a function."""
+ group_data = {}
+ for opt_obj in cls.OPTIONS:
+ group_data.setdefault(opt_obj.group, []).append(f'--{opt_obj.name}')
+ function = opt_obj.to_click_option_dec()(function)
+ lst = []
+ for key, value in group_data.items():
+ lst.append({
+ 'name': key,
+ # 'description': f'Options for {key}',
+ 'options': value
+ })
+ opt_group[function.__name__] = lst
+ return function
+
+ @classmethod
+ def register_option(cls, group: str, name: str, data: tuple, prefix: str = '') -> None:
+ """Register an EasyBuild option."""
+ if prefix:
+ name = f"{prefix}-{name}"
+ if name == 'help':
+ return
+ short = None
+ meta = None
+ lst = None
+ descr, typ, action, default, *others = data
+ while others:
+ opt = others.pop(0)
+ if isinstance(opt, str):
+ if short is not None:
+ raise ValueError(f"Short option already set: {short} for {name}")
+ short = opt
+ elif isinstance(opt, dict):
+ if meta is not None:
+ raise ValueError(f"Meta already set: {meta} for {name}")
+ meta = opt
+ elif isinstance(opt, (list, tuple)):
+ if lst is not None:
+ raise ValueError(f"List already set: {lst} for {name}")
+ lst = opt
+ else:
+ raise ValueError(f"Unexpected type for others: {type(others[0])} in {others}")
+
+ opt = OptionData(
+ group=group,
+ name=name,
+ description=descr,
+ type=typ,
+ action=action,
+ default=default,
+ short=short,
+ meta=meta,
+ lst=lst
+ )
+ cls.OPTIONS_MAP[name] = opt
+ cls.OPTIONS.append(opt)
+
+
+for grp, dct in extracter._option_dicts.items():
+ prefix, dct = dct
+ if dct is None:
+ continue
+ for key, value in dct.items():
+ # print(f"Registering option: group={grp}, key={key}, value={value}, prefix={prefix}")
+ EasyBuildCliOption.register_option(grp, key, value, prefix=prefix)
diff --git a/easybuild/main.py b/easybuild/main.py
index dda45eb00d..ea65fb2a54 100755
--- a/easybuild/main.py
+++ b/easybuild/main.py
@@ -865,6 +865,9 @@ def main_with_hooks(args=None):
init_session_state, eb_go, cfg_settings = prepare_main(args=args)
except EasyBuildError as err:
print_error_and_exit(err.msg, exit_code=err.exit_code)
+ else:
+ # Avoid running double initialization in `main` afterward if `prepare_main` succeeded
+ args = None
hooks = load_hooks(eb_go.options.hooks)
diff --git a/eb b/eb
index ae68bbe888..5b2c03af15 100755
--- a/eb
+++ b/eb
@@ -44,7 +44,12 @@ trap keyboard_interrupt SIGINT
# Python 3.6+ required
REQ_MIN_PY3VER=6
-EASYBUILD_MAIN='easybuild.main'
+if [ "${EB_CLI_CLICK}" == "1" ]; then
+ # opt-in to using Click-based CLI (if click is available)
+ EASYBUILD_MAIN='easybuild.cli'
+else
+ EASYBUILD_MAIN='easybuild.main'
+fi
# easybuild module to import to check whether EasyBuild framework is available;
# don't use easybuild.main here, since that's a very expensive module to import (it makes the 'eb' command slow)
diff --git a/setup.py b/setup.py
index 2f1a798a28..14810f24bd 100644
--- a/setup.py
+++ b/setup.py
@@ -77,6 +77,7 @@ def find_rel_test():
"easybuild.tools.module_naming_scheme", "easybuild.tools.package", "easybuild.tools.package.package_naming_scheme",
"easybuild.tools.py2vs3", "easybuild.tools.repository",
"easybuild.tools.tomllib", "easybuild.tools.tomllib.tomli", "easybuild.tools._toml_writer",
+ "easybuild.cli", "easybuild.cli.options",
"test.framework", "test",
]
@@ -119,6 +120,9 @@ def find_rel_test():
# utility scripts
'easybuild/scripts/install_eb_dep.sh',
],
+ extras_require={
+ 'eb_click': ['click', 'rich', 'rich_click'],
+ },
data_files=[
('easybuild/scripts', glob.glob('easybuild/scripts/*')),
('etc', glob.glob('etc/*')),