Coverage for /home/runner/work/bijux-cli/bijux-cli/src/bijux_cli/commands/__init__.py: 100%
73 statements
« prev ^ index » next coverage.py v7.10.4, created at 2025-08-19 23:36 +0000
« prev ^ index » next coverage.py v7.10.4, created at 2025-08-19 23:36 +0000
1# SPDX-License-Identifier: MIT
2# Copyright © 2025 Bijan Mousavi
4"""Constructs the command structure for the Bijux CLI application.
6This module is responsible for assembling the entire CLI by registering all
7core command groups and dynamically discovering and loading external plugins.
8It provides the central mechanism that makes the CLI's command system modular
9and extensible.
11The primary functions are:
12* `register_commands`: Attaches all built-in command `Typer` applications
13 (e.g., `config_app`, `plugins_app`) to the main root application.
14* `register_dynamic_plugins`: Scans for plugins from package entry points
15 and the local plugins directory, loading and attaching them to the root
16 application. Errors during this process are logged as warnings.
17* `list_registered_command_names`: Provides a way to retrieve the names of all
18 successfully registered commands, including dynamic plugins.
19"""
21from __future__ import annotations
23import logging
25from typer import Typer
27from bijux_cli.commands.audit import audit_app
28from bijux_cli.commands.config import config_app
29from bijux_cli.commands.dev import dev_app
30from bijux_cli.commands.docs import docs_app
31from bijux_cli.commands.doctor import doctor_app
32from bijux_cli.commands.help import help_app
33from bijux_cli.commands.history import history_app
34from bijux_cli.commands.memory import memory_app
35from bijux_cli.commands.plugins import plugins_app
36from bijux_cli.commands.repl import repl_app
37from bijux_cli.commands.sleep import sleep_app
38from bijux_cli.commands.status import status_app
39from bijux_cli.commands.version import version_app
41logger = logging.getLogger(__name__)
43_CORE_COMMANDS = {
44 "audit": audit_app,
45 "config": config_app,
46 "dev": dev_app,
47 "docs": docs_app,
48 "doctor": doctor_app,
49 "help": help_app,
50 "history": history_app,
51 "memory": memory_app,
52 "plugins": plugins_app,
53 "repl": repl_app,
54 "status": status_app,
55 "version": version_app,
56 "sleep": sleep_app,
57}
58_REGISTERED_COMMANDS: set[str] = set(_CORE_COMMANDS.keys())
61def register_commands(app: Typer) -> list[str]:
62 """Registers all core, built-in commands with the main Typer application.
64 Args:
65 app (Typer): The main Typer application to which commands will be added.
67 Returns:
68 list[str]: An alphabetically sorted list of the names of the registered
69 core commands.
70 """
71 for name, cmd in sorted(_CORE_COMMANDS.items()):
72 app.add_typer(cmd, name=name, invoke_without_command=True)
73 _REGISTERED_COMMANDS.add(name)
74 return sorted(_CORE_COMMANDS.keys())
77def register_dynamic_plugins(app: Typer) -> None:
78 """Discovers and registers all third-party plugins.
80 This function scans for plugins from two sources:
81 1. Python package entry points registered under the `bijux_cli.plugins` group.
82 2. Subdirectories within the local plugins folder that contain a `plugin.py`.
84 For each discovered plugin, this function expects the loaded module to expose
85 either a callable `cli()` that returns a `Typer` app or an `app` attribute
86 that is a `Typer` instance. All discovery and loading errors are logged
87 and suppressed to prevent a single faulty plugin from crashing the CLI.
89 Args:
90 app (Typer): The root Typer application to which discovered plugin
91 apps will be attached.
93 Returns:
94 None:
95 """
96 import importlib.util
97 import sys
99 try:
100 import importlib.metadata
102 eps = importlib.metadata.entry_points()
103 for ep in eps.select(group="bijux_cli.plugins"):
104 try:
105 plugin_app = ep.load()
106 app.add_typer(plugin_app, name=ep.name)
107 _REGISTERED_COMMANDS.add(ep.name)
108 except Exception as exc:
109 logger.debug("Failed to load entry-point plugin %r: %s", ep.name, exc)
110 except Exception as e:
111 logger.debug("Entry points loading failed: %s", e)
113 try:
114 from bijux_cli.services.plugins import get_plugins_dir
116 plugins_dir = get_plugins_dir()
117 for pdir in plugins_dir.iterdir():
118 plug_py = pdir / "plugin.py"
119 if not plug_py.is_file():
120 continue
121 mod_name = f"_bijux_cli_plugin_{pdir.name}"
122 spec = importlib.util.spec_from_file_location(mod_name, plug_py)
123 if not spec or not spec.loader:
124 continue
125 module = importlib.util.module_from_spec(spec)
126 sys.modules[mod_name] = module
127 try:
128 spec.loader.exec_module(module)
129 plugin_app = None
130 if hasattr(module, "cli") and callable(module.cli):
131 plugin_app = module.cli()
132 elif hasattr(module, "app"):
133 plugin_app = module.app
134 else:
135 logger.debug(
136 "Plugin %r has no CLI entrypoint (no cli()/app).", pdir.name
137 )
138 continue
139 if isinstance(plugin_app, Typer):
140 app.add_typer(plugin_app, name=pdir.name)
141 _REGISTERED_COMMANDS.add(pdir.name)
142 else:
143 logger.debug(
144 "Plugin %r loaded but did not return a Typer app instance.",
145 pdir.name,
146 )
147 except Exception as exc:
148 logger.debug("Failed to load local plugin %r: %s", pdir.name, exc)
149 finally:
150 sys.modules.pop(mod_name, None)
151 except Exception as e:
152 logger.debug("Dynamic plugin discovery failed: %s", e)
155def list_registered_command_names() -> list[str]:
156 """Returns a list of all registered command names.
158 This includes both core commands and any dynamically loaded plugins.
160 Returns:
161 list[str]: An alphabetically sorted list of all command names.
162 """
163 return sorted(_REGISTERED_COMMANDS)
166__all__ = [
167 "register_commands",
168 "register_dynamic_plugins",
169 "list_registered_command_names",
170]