bijux-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. bijux_cli/__init__.py +39 -0
  2. bijux_cli/__main__.py +417 -0
  3. bijux_cli/__version__.py +42 -0
  4. bijux_cli/api.py +326 -0
  5. bijux_cli/cli.py +68 -0
  6. bijux_cli/commands/__init__.py +170 -0
  7. bijux_cli/commands/audit.py +293 -0
  8. bijux_cli/commands/config/__init__.py +85 -0
  9. bijux_cli/commands/config/clear.py +124 -0
  10. bijux_cli/commands/config/export.py +148 -0
  11. bijux_cli/commands/config/get.py +141 -0
  12. bijux_cli/commands/config/list_cmd.py +127 -0
  13. bijux_cli/commands/config/load.py +128 -0
  14. bijux_cli/commands/config/py.typed +0 -0
  15. bijux_cli/commands/config/reload.py +125 -0
  16. bijux_cli/commands/config/service.py +107 -0
  17. bijux_cli/commands/config/set.py +260 -0
  18. bijux_cli/commands/config/unset.py +140 -0
  19. bijux_cli/commands/dev/__init__.py +46 -0
  20. bijux_cli/commands/dev/di.py +285 -0
  21. bijux_cli/commands/dev/list_plugins.py +66 -0
  22. bijux_cli/commands/dev/py.typed +0 -0
  23. bijux_cli/commands/dev/service.py +120 -0
  24. bijux_cli/commands/docs.py +329 -0
  25. bijux_cli/commands/doctor.py +181 -0
  26. bijux_cli/commands/help.py +436 -0
  27. bijux_cli/commands/history/__init__.py +43 -0
  28. bijux_cli/commands/history/clear.py +160 -0
  29. bijux_cli/commands/history/py.typed +0 -0
  30. bijux_cli/commands/history/service.py +362 -0
  31. bijux_cli/commands/memory/__init__.py +59 -0
  32. bijux_cli/commands/memory/clear.py +114 -0
  33. bijux_cli/commands/memory/delete.py +144 -0
  34. bijux_cli/commands/memory/get.py +146 -0
  35. bijux_cli/commands/memory/list.py +115 -0
  36. bijux_cli/commands/memory/py.typed +0 -0
  37. bijux_cli/commands/memory/service.py +242 -0
  38. bijux_cli/commands/memory/set.py +136 -0
  39. bijux_cli/commands/memory/utils.py +54 -0
  40. bijux_cli/commands/plugins/__init__.py +59 -0
  41. bijux_cli/commands/plugins/check.py +240 -0
  42. bijux_cli/commands/plugins/info.py +143 -0
  43. bijux_cli/commands/plugins/install.py +327 -0
  44. bijux_cli/commands/plugins/list.py +73 -0
  45. bijux_cli/commands/plugins/py.typed +0 -0
  46. bijux_cli/commands/plugins/scaffold.py +287 -0
  47. bijux_cli/commands/plugins/uninstall.py +206 -0
  48. bijux_cli/commands/plugins/utils.py +158 -0
  49. bijux_cli/commands/py.typed +0 -0
  50. bijux_cli/commands/repl.py +709 -0
  51. bijux_cli/commands/sleep.py +169 -0
  52. bijux_cli/commands/status.py +289 -0
  53. bijux_cli/commands/utilities.py +637 -0
  54. bijux_cli/commands/version.py +152 -0
  55. bijux_cli/contracts/__init__.py +48 -0
  56. bijux_cli/contracts/audit.py +66 -0
  57. bijux_cli/contracts/config.py +127 -0
  58. bijux_cli/contracts/context.py +93 -0
  59. bijux_cli/contracts/docs.py +65 -0
  60. bijux_cli/contracts/doctor.py +32 -0
  61. bijux_cli/contracts/emitter.py +58 -0
  62. bijux_cli/contracts/history.py +101 -0
  63. bijux_cli/contracts/memory.py +76 -0
  64. bijux_cli/contracts/observability.py +94 -0
  65. bijux_cli/contracts/process.py +48 -0
  66. bijux_cli/contracts/py.typed +0 -0
  67. bijux_cli/contracts/registry.py +115 -0
  68. bijux_cli/contracts/retry.py +53 -0
  69. bijux_cli/contracts/serializer.py +100 -0
  70. bijux_cli/contracts/telemetry.py +46 -0
  71. bijux_cli/core/__init__.py +44 -0
  72. bijux_cli/core/constants.py +21 -0
  73. bijux_cli/core/context.py +218 -0
  74. bijux_cli/core/di.py +695 -0
  75. bijux_cli/core/engine.py +199 -0
  76. bijux_cli/core/enums.py +52 -0
  77. bijux_cli/core/exceptions.py +184 -0
  78. bijux_cli/core/paths.py +21 -0
  79. bijux_cli/core/py.typed +0 -0
  80. bijux_cli/httpapi.py +552 -0
  81. bijux_cli/infra/__init__.py +43 -0
  82. bijux_cli/infra/emitter.py +140 -0
  83. bijux_cli/infra/observability.py +148 -0
  84. bijux_cli/infra/process.py +177 -0
  85. bijux_cli/infra/py.typed +0 -0
  86. bijux_cli/infra/retry.py +300 -0
  87. bijux_cli/infra/serializer.py +573 -0
  88. bijux_cli/infra/telemetry.py +159 -0
  89. bijux_cli/py.typed +0 -0
  90. bijux_cli/services/__init__.py +233 -0
  91. bijux_cli/services/audit.py +268 -0
  92. bijux_cli/services/config.py +677 -0
  93. bijux_cli/services/docs.py +170 -0
  94. bijux_cli/services/doctor.py +34 -0
  95. bijux_cli/services/history.py +527 -0
  96. bijux_cli/services/memory.py +129 -0
  97. bijux_cli/services/plugins/__init__.py +346 -0
  98. bijux_cli/services/plugins/entrypoints.py +151 -0
  99. bijux_cli/services/plugins/groups.py +177 -0
  100. bijux_cli/services/plugins/hooks.py +113 -0
  101. bijux_cli/services/plugins/py.typed +0 -0
  102. bijux_cli/services/plugins/registry.py +284 -0
  103. bijux_cli/services/py.typed +0 -0
  104. bijux_cli/services/utils.py +68 -0
  105. bijux_cli-0.1.0.dist-info/METADATA +297 -0
  106. bijux_cli-0.1.0.dist-info/RECORD +108 -0
  107. bijux_cli-0.1.0.dist-info/WHEEL +4 -0
  108. bijux_cli-0.1.0.dist-info/entry_points.txt +2 -0
bijux_cli/api.py ADDED
@@ -0,0 +1,326 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright © 2025 Bijan Mousavi
3
+
4
+ """Provides a high-level, synchronous facade for the Bijux CLI's core engine.
5
+
6
+ This module defines the `BijuxAPI` class, which serves as the primary public
7
+ interface for programmatic interaction with the CLI. It wraps the asynchronous
8
+ core `Engine` and other services to present a stable, thread-safe, and
9
+ synchronous API.
10
+
11
+ This facade is intended for use in integrations, testing, or any scenario
12
+ where the CLI's command and plugin management logic needs to be embedded
13
+ within another Python application.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ from collections.abc import Callable, Coroutine
20
+ import importlib
21
+ import os
22
+ from pathlib import Path
23
+ import sys
24
+ from typing import Any
25
+
26
+ from bijux_cli.commands.utilities import validate_common_flags
27
+ from bijux_cli.contracts import (
28
+ ObservabilityProtocol,
29
+ RegistryProtocol,
30
+ TelemetryProtocol,
31
+ )
32
+ from bijux_cli.core.di import DIContainer
33
+ from bijux_cli.core.engine import Engine
34
+ from bijux_cli.core.enums import OutputFormat
35
+ from bijux_cli.core.exceptions import BijuxError, CommandError, ServiceError
36
+
37
+ IGNORE = {"PS1", "LS_COLORS", "PROMPT_COMMAND", "GIT_PS1_FORMAT"}
38
+
39
+
40
+ class BijuxAPI:
41
+ """A thread-safe, synchronous access layer for the Bijux CLI engine.
42
+
43
+ This class provides a stable public API for registering commands, executing
44
+ them, and managing plugins. It wraps the internal asynchronous `Engine` to
45
+ allow for simpler, synchronous integration into other applications.
46
+
47
+ Attributes:
48
+ _di (DIContainer): The dependency injection container.
49
+ _engine (Engine): The core asynchronous runtime engine.
50
+ _registry (RegistryProtocol): The plugin registry service.
51
+ _obs (ObservabilityProtocol): The logging service.
52
+ _tel (TelemetryProtocol): The telemetry service.
53
+ """
54
+
55
+ def __init__(self, *, debug: bool = False) -> None:
56
+ """Initializes the `BijuxAPI` and the underlying CLI engine.
57
+
58
+ Args:
59
+ debug (bool): If True, enables debug mode for all underlying
60
+ services. Defaults to False.
61
+ """
62
+ DIContainer.reset()
63
+ self._di = DIContainer.current()
64
+ self._engine = Engine(
65
+ self._di,
66
+ debug=debug,
67
+ fmt=OutputFormat.JSON,
68
+ )
69
+ self._registry: RegistryProtocol = self._di.resolve(RegistryProtocol)
70
+ self._obs: ObservabilityProtocol = self._di.resolve(ObservabilityProtocol)
71
+ self._tel: TelemetryProtocol = self._di.resolve(TelemetryProtocol)
72
+
73
+ def _schedule_event(self, name: str, payload: dict[str, Any]) -> None:
74
+ """Schedules a "fire-and-forget" asynchronous telemetry event.
75
+
76
+ This helper handles the execution of async telemetry calls from a
77
+ synchronous context.
78
+
79
+ Args:
80
+ name (str): The name of the telemetry event.
81
+ payload (dict[str, Any]): The data associated with the event.
82
+ """
83
+ maybe_coro: Coroutine[Any, Any, None] | None = self._tel.event(name, payload)
84
+ if asyncio.iscoroutine(maybe_coro):
85
+ try:
86
+ loop = asyncio.get_running_loop()
87
+ loop.create_task(maybe_coro)
88
+ except RuntimeError:
89
+ asyncio.run(maybe_coro)
90
+
91
+ def register(self, name: str, callback: Callable[..., Any]) -> None:
92
+ """Registers or replaces a Python callable as a CLI command.
93
+
94
+ The provided callable is wrapped to handle both synchronous and
95
+ asynchronous functions automatically.
96
+
97
+ Args:
98
+ name (str): The command name to register.
99
+ callback (Callable[..., Any]): The Python function to be executed
100
+ when the command is run.
101
+
102
+ Raises:
103
+ BijuxError: If the command name is already in use or another
104
+ registration error occurs.
105
+ """
106
+
107
+ class _Wrapper:
108
+ """Wraps a user-provided callable to be executed asynchronously."""
109
+
110
+ def __init__(self, cb: Callable[..., Any]) -> None:
111
+ """Initializes the wrapper.
112
+
113
+ Args:
114
+ cb (Callable[..., Any]): The callable to wrap.
115
+ """
116
+ self._cb = cb
117
+
118
+ async def execute(self, *args: Any, **kwargs: Any) -> Any:
119
+ """Execute the wrapped callable, awaiting if it's a coroutine.
120
+
121
+ Args:
122
+ *args (Any): Positional arguments to pass to the callable.
123
+ **kwargs (Any): Keyword arguments to pass to the callable.
124
+
125
+ Returns:
126
+ Any: The result of the callable execution.
127
+ """
128
+ if asyncio.iscoroutinefunction(self._cb):
129
+ return await self._cb(*args, **kwargs)
130
+ return self._cb(*args, **kwargs)
131
+
132
+ try:
133
+ if self._registry.has(name):
134
+ self._registry.deregister(name)
135
+ self._registry.register(name, _Wrapper(callback))
136
+ self._obs.log("info", "Registered command", extra={"name": name})
137
+ self._schedule_event("api.register", {"name": name})
138
+ except ServiceError as exc:
139
+ self._schedule_event(
140
+ "api.register.error", {"name": name, "error": str(exc)}
141
+ )
142
+ raise BijuxError(
143
+ f"Could not register command {name}: {exc}", http_status=500
144
+ ) from exc
145
+
146
+ def run_sync(
147
+ self,
148
+ name: str,
149
+ *args: Any,
150
+ quiet: bool = False,
151
+ verbose: bool = False,
152
+ fmt: str = "json",
153
+ pretty: bool = True,
154
+ debug: bool = False,
155
+ **kwargs: Any,
156
+ ) -> Any:
157
+ """Runs a command synchronously.
158
+
159
+ This method is a blocking wrapper around the asynchronous `run_async`
160
+ method. It manages the asyncio event loop to provide a simple,
161
+ synchronous interface.
162
+
163
+ Args:
164
+ name (str): The name of the command to run.
165
+ *args (Any): Positional arguments for the command.
166
+ quiet (bool): If True, suppresses output.
167
+ verbose (bool): If True, enables verbose logging.
168
+ fmt (str): The output format ("json" or "yaml").
169
+ pretty (bool): If True, formats the output for readability.
170
+ debug (bool): If True, enables debug mode.
171
+ **kwargs (Any): Additional keyword arguments to pass to the command.
172
+
173
+ Returns:
174
+ Any: The result of the command's execution.
175
+ """
176
+ try:
177
+ loop = asyncio.get_running_loop()
178
+ except RuntimeError:
179
+ return asyncio.run(
180
+ self.run_async(
181
+ name,
182
+ *args,
183
+ quiet=quiet,
184
+ verbose=verbose,
185
+ fmt=fmt,
186
+ pretty=pretty,
187
+ debug=debug,
188
+ **kwargs,
189
+ )
190
+ )
191
+ else:
192
+ return loop.run_until_complete(
193
+ self.run_async(
194
+ name,
195
+ *args,
196
+ quiet=quiet,
197
+ verbose=verbose,
198
+ fmt=fmt,
199
+ pretty=pretty,
200
+ debug=debug,
201
+ **kwargs,
202
+ )
203
+ )
204
+
205
+ async def run_async(
206
+ self,
207
+ name: str,
208
+ *args: Any,
209
+ quiet: bool = False,
210
+ verbose: bool = False,
211
+ fmt: str = "json",
212
+ pretty: bool = True,
213
+ debug: bool = False,
214
+ **kwargs: Any,
215
+ ) -> Any:
216
+ """Runs a command asynchronously with validation.
217
+
218
+ This method performs validation of flags and environment variables
219
+ before dispatching the command to the internal engine for execution.
220
+
221
+ Args:
222
+ name (str): The name of the command to execute.
223
+ *args (Any): Positional arguments for the command.
224
+ quiet (bool): If True, suppresses output.
225
+ verbose (bool): If True, enables verbose output.
226
+ fmt (str): The output format ("json" or "yaml").
227
+ pretty (bool): If True, formats the output for readability.
228
+ debug (bool): If True, enables debug mode.
229
+ **kwargs (Any): Additional keyword arguments to pass to the command.
230
+
231
+ Returns:
232
+ Any: The result of the command's execution.
233
+
234
+ Raises:
235
+ BijuxError: For invalid flags, unsupported formats, or internal
236
+ execution errors.
237
+ """
238
+ try:
239
+ fmt = fmt.lower()
240
+ if fmt not in ("json", "yaml"):
241
+ raise BijuxError("Unsupported format", http_status=400)
242
+
243
+ if quiet and (verbose or debug):
244
+ raise BijuxError(
245
+ "--quiet cannot be combined with --verbose/--debug", http_status=400
246
+ )
247
+
248
+ validate_common_flags(fmt, name, quiet, verbose or debug)
249
+
250
+ for k, v in os.environ.items():
251
+ if k in IGNORE:
252
+ continue
253
+ if not v.isascii():
254
+ raise BijuxError(
255
+ "Non-ASCII characters in environment", http_status=400
256
+ )
257
+
258
+ result = await self._engine.run_command(name, *args, **kwargs)
259
+ self._schedule_event("api.run", {"name": name})
260
+ return result
261
+
262
+ except CommandError as exc:
263
+ self._schedule_event("api.run.error", {"name": name, "error": str(exc)})
264
+ raise BijuxError(
265
+ f"Failed to run command {name}: {exc}", http_status=500
266
+ ) from exc
267
+
268
+ except ServiceError as exc:
269
+ self._schedule_event("api.run.error", {"name": name, "error": str(exc)})
270
+ raise BijuxError(
271
+ f"Failed to run command {name}: {exc}", http_status=500
272
+ ) from exc
273
+
274
+ except BijuxError:
275
+ raise
276
+
277
+ except Exception as exc:
278
+ self._schedule_event("api.run.error", {"name": name, "error": str(exc)})
279
+ raise BijuxError(
280
+ f"Failed to run command {name}: {exc}", http_status=500
281
+ ) from exc
282
+
283
+ def load_plugin(self, path: str | Path) -> None:
284
+ """Loads or reloads a plugin module from a file path.
285
+
286
+ This method dynamically loads the specified plugin file, initializes it,
287
+ and registers it with the CLI system. If the plugin is already loaded,
288
+ it is reloaded.
289
+
290
+ Args:
291
+ path (str | Path): The filesystem path to the plugin's Python file.
292
+
293
+ Raises:
294
+ BijuxError: If plugin loading, initialization, or registration fails.
295
+ """
296
+ from bijux_cli.__version__ import __version__
297
+ from bijux_cli.services.plugins import load_plugin as _load_plugin
298
+
299
+ p = Path(path).expanduser().resolve()
300
+ module_name = f"bijux_plugin_{p.stem}"
301
+
302
+ try:
303
+ if module_name in sys.modules:
304
+ importlib.reload(sys.modules[module_name])
305
+
306
+ plugin = _load_plugin(p, module_name)
307
+ plugin.startup(self._engine.di)
308
+
309
+ if self._registry.has(p.stem):
310
+ self._registry.deregister(p.stem)
311
+
312
+ self._registry.register(
313
+ p.stem,
314
+ plugin,
315
+ alias=str(__version__),
316
+ )
317
+ self._obs.log("info", "Loaded plugin", extra={"path": str(p)})
318
+ self._schedule_event("api.plugin_loaded", {"path": str(p)})
319
+
320
+ except Exception as exc:
321
+ self._schedule_event(
322
+ "api.plugin_load.error", {"path": str(p), "error": str(exc)}
323
+ )
324
+ raise BijuxError(
325
+ f"Failed to load plugin {p}: {exc}", http_status=500
326
+ ) from exc
bijux_cli/cli.py ADDED
@@ -0,0 +1,68 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright © 2025 Bijan Mousavi
3
+
4
+ """Constructs the main `Typer` application for the Bijux CLI.
5
+
6
+ This module serves as the primary builder for the entire CLI. It defines the
7
+ root `Typer` app, orchestrates the registration of all core commands and
8
+ the discovery of dynamic plugins, and sets the default behavior for when the
9
+ CLI is invoked without any command.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import subprocess # nosec B404
15
+ import sys
16
+
17
+ import typer
18
+ from typer import Context
19
+
20
+ from bijux_cli.commands import register_commands, register_dynamic_plugins
21
+
22
+
23
+ def maybe_default_to_repl(ctx: Context) -> None:
24
+ """Invokes the `repl` command if no other subcommand is specified.
25
+
26
+ This function is used as the root callback for the main `Typer` application.
27
+ It checks if a subcommand was invoked and, if not, re-executes the CLI
28
+ with the `repl` command.
29
+
30
+ Args:
31
+ ctx (Context): The Typer context, used to check for an invoked subcommand.
32
+
33
+ Returns:
34
+ None:
35
+ """
36
+ if ctx.invoked_subcommand is None:
37
+ subprocess.call([sys.argv[0], "repl"]) # noqa: S603 # nosec B603
38
+
39
+
40
+ def build_app() -> typer.Typer:
41
+ """Builds and configures the root `Typer` application.
42
+
43
+ This factory function performs the main steps of assembling the CLI:
44
+ 1. Creates the root `Typer` app instance.
45
+ 2. Registers all core, built-in commands.
46
+ 3. Discovers and registers all dynamic plugins.
47
+ 4. Sets the default callback to launch the REPL.
48
+
49
+ Returns:
50
+ typer.Typer: The fully constructed `Typer` application.
51
+ """
52
+ app = typer.Typer(
53
+ help="Bijux CLI – Lean, plug-in‑driven command‑line interface.",
54
+ invoke_without_command=True,
55
+ context_settings={
56
+ "ignore_unknown_options": True,
57
+ "allow_extra_args": True,
58
+ },
59
+ )
60
+ register_commands(app)
61
+ register_dynamic_plugins(app)
62
+ app.callback(invoke_without_command=True)(maybe_default_to_repl)
63
+ return app
64
+
65
+
66
+ app = build_app()
67
+
68
+ __all__ = ["build_app", "app"]
@@ -0,0 +1,170 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright © 2025 Bijan Mousavi
3
+
4
+ """Constructs the command structure for the Bijux CLI application.
5
+
6
+ This module is responsible for assembling the entire CLI by registering all
7
+ core command groups and dynamically discovering and loading external plugins.
8
+ It provides the central mechanism that makes the CLI's command system modular
9
+ and extensible.
10
+
11
+ The 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
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+
25
+ from typer import Typer
26
+
27
+ from bijux_cli.commands.audit import audit_app
28
+ from bijux_cli.commands.config import config_app
29
+ from bijux_cli.commands.dev import dev_app
30
+ from bijux_cli.commands.docs import docs_app
31
+ from bijux_cli.commands.doctor import doctor_app
32
+ from bijux_cli.commands.help import help_app
33
+ from bijux_cli.commands.history import history_app
34
+ from bijux_cli.commands.memory import memory_app
35
+ from bijux_cli.commands.plugins import plugins_app
36
+ from bijux_cli.commands.repl import repl_app
37
+ from bijux_cli.commands.sleep import sleep_app
38
+ from bijux_cli.commands.status import status_app
39
+ from bijux_cli.commands.version import version_app
40
+
41
+ logger = 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
+
61
+ def 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
+
77
+ def 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
+
155
+ def 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
+ ]