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

1# SPDX-License-Identifier: MIT 

2# Copyright © 2025 Bijan Mousavi 

3 

4"""Constructs the command structure for the Bijux CLI application. 

5 

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. 

10 

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""" 

20 

21from __future__ import annotations 

22 

23import logging 

24 

25from typer import Typer 

26 

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 

40 

41logger = logging.getLogger(__name__) 

42 

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()) 

59 

60 

61def register_commands(app: Typer) -> list[str]: 

62 """Registers all core, built-in commands with the main Typer application. 

63 

64 Args: 

65 app (Typer): The main Typer application to which commands will be added. 

66 

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()) 

75 

76 

77def register_dynamic_plugins(app: Typer) -> None: 

78 """Discovers and registers all third-party plugins. 

79 

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`. 

83 

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. 

88 

89 Args: 

90 app (Typer): The root Typer application to which discovered plugin 

91 apps will be attached. 

92 

93 Returns: 

94 None: 

95 """ 

96 import importlib.util 

97 import sys 

98 

99 try: 

100 import importlib.metadata 

101 

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) 

112 

113 try: 

114 from bijux_cli.services.plugins import get_plugins_dir 

115 

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) 

153 

154 

155def list_registered_command_names() -> list[str]: 

156 """Returns a list of all registered command names. 

157 

158 This includes both core commands and any dynamically loaded plugins. 

159 

160 Returns: 

161 list[str]: An alphabetically sorted list of all command names. 

162 """ 

163 return sorted(_REGISTERED_COMMANDS) 

164 

165 

166__all__ = [ 

167 "register_commands", 

168 "register_dynamic_plugins", 

169 "list_registered_command_names", 

170]