Skip to content

Commit 05683a8

Browse files
committed
feat(cli): let external packages add nooa subcommands via entry points
`nooa_cli.commands` discovered commands by scanning only its own directory, so nothing outside this repo could add a `nooa` subcommand. Packages that wanted to extend the CLI had to ship standalone console scripts instead, changing the command name for every user. Split `discover_commands()` into the existing built-in scan plus a new pass over the `nooa_cli.commands` entry-point group, mirroring the existing `nooa.skills` and `nooa.bundled_configs` groups: [project.entry-points."nooa_cli.commands"] tui = "my_package.cli.tui:command" Built-ins are yielded first and win name collisions, so a third party cannot shadow `eval` or `config`; each name is yielded exactly once. Every plugin failure — broken distribution metadata, an entry point that fails to import, or one resolving to a non-`click.Command` — is logged at WARNING and skipped, since these are arbitrary third-party imports and must never make `nooa` unusable. The built-in scan deliberately keeps raising `TypeError`: that is a bug in this repo and should be loud. Plugins are sorted by entry-point name so `nooa --help` does not depend on install order. Two test-collection fixes were needed to make the new tests actually run: `testpaths` pointed at `packages/nooa-cli/tests/{cli,integration}`, neither of which exists, so nothing under `packages/nooa-cli/tests` was ever collected in CI. Replacing both with the parent directory exposed a latent collision — the empty `packages/nooa-cli/tests/__init__.py` made it a package named `tests`, shadowing the root `tests` package that other modules import from. It is unnecessary under the repo's `--import-mode=importlib`, and `packages/nooa-bench/tests` already ships without one. Signed-off-by: Severin Klingler <sklingler@nvidia.com>
1 parent 51fb19f commit 05683a8

6 files changed

Lines changed: 382 additions & 7 deletions

File tree

packages/nooa-cli/src/nooa_cli/AGENTS.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,35 @@ def command(args: tuple[str, ...]):
101101
subprocess.run([sys.executable, "-m", "some_other_tool", *args])
102102
```
103103

104+
## Commands From Another Package
105+
106+
If your command lives in a *different* installed package (not in this repo),
107+
register it in the `nooa_cli.commands` entry-point group instead of dropping a
108+
file here. The entry-point name becomes the subcommand name:
109+
110+
```toml
111+
# pyproject.toml of your package
112+
[project.entry-points."nooa_cli.commands"]
113+
tui = "my_package.cli.tui:command"
114+
term = "my_package.cli.term:command"
115+
```
116+
117+
`my_package.cli.tui:command` must be a `click.Command` or `click.Group` — the
118+
same contract as an in-repo command module.
119+
120+
- **Built-ins win name collisions.** A plugin can't shadow `eval`, `config`,
121+
or anything else shipped here; it's logged and skipped.
122+
- **A broken plugin is skipped, not fatal.** An entry point that fails to
123+
import, or that resolves to a non-`click.Command`, logs a warning and is
124+
left out. `nooa` keeps working.
125+
- Plugins register in entry-point-name order, so `nooa --help` is stable
126+
regardless of install order.
127+
- The **Performance Rule** below applies with extra force: every registered
128+
entry point is loaded on *every* `nooa` invocation, including `nooa --help`.
129+
Keep heavy imports inside the handler.
130+
131+
This mirrors the existing `nooa.skills` and `nooa.bundled_configs` groups.
132+
104133
## Shared Utilities
105134

106135
Common helpers live in `src/nooa_cli/_common.py`:

packages/nooa-cli/src/nooa_cli/__init__.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99
nooa completion install # Set up shell completions
1010
1111
Adding new commands:
12-
Drop a .py file in nooa_cli/commands/ — see commands/_template.py
12+
In this package: drop a .py file in nooa_cli/commands/ — see
13+
commands/_template.py
14+
From another package: register an entry point in the
15+
"nooa_cli.commands" group — see nooa_cli/commands/__init__.py
1316
1417
Shell completion:
1518
eval "$(_NOOA_COMPLETE=bash_source nooa)" # bash
@@ -36,7 +39,8 @@ def oo(ctx):
3639
"""OO Agents — agent toolkit.
3740
3841
Extensible CLI for running agents, evaluations, and trace management.
39-
Add new commands by dropping a .py file in nooa_cli/commands/.
42+
Add new commands by dropping a .py file in nooa_cli/commands/, or from
43+
another package via the "nooa_cli.commands" entry-point group.
4044
"""
4145
if ctx.invoked_subcommand in _SKIP_SECRETS_PRELOAD:
4246
return
@@ -57,11 +61,14 @@ def oo(ctx):
5761
)
5862

5963

60-
# -- Auto-discover and register all commands from commands/ -----------------
64+
# -- Auto-discover and register all commands (built-ins + entry-point plugins) --
6165
for _name, _cmd in discover_commands():
6266
oo.add_command(_cmd, name=_name)
6367

6468
# -- Built-in infrastructure commands (not in commands/ because they're meta) -
69+
# Registered *after* discovery on purpose: `completion` is not part of
70+
# discover_commands(), so this ordering is what stops a third-party plugin
71+
# named "completion" from taking the name.
6572
oo.add_command(completion)
6673

6774

packages/nooa-cli/src/nooa_cli/commands/__init__.py

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
║ ║
1414
║ See _template.py for a copy-paste starter. ║
1515
║ ║
16+
║ From *another* package? Register an entry point instead — ║
17+
║ see "Commands from other packages" below. ║
18+
║ ║
1619
╚══════════════════════════════════════════════════════════════════════╝
1720
1821
Convention
@@ -62,22 +65,58 @@ def list():
6265
def create(name):
6366
\"\"\"Create a new thing.\"\"\"
6467
click.echo(f"Created {name}")
68+
69+
Commands from other packages
70+
----------------------------
71+
72+
A package installed alongside ``nooa-cli`` can contribute top-level
73+
subcommands through the ``nooa_cli.commands`` entry-point group. The
74+
entry-point name is the subcommand name; the object it points at must be a
75+
``click.Command``::
76+
77+
# pyproject.toml of the contributing package
78+
[project.entry-points."nooa_cli.commands"]
79+
tui = "my_package.cli.tui:command"
80+
term = "my_package.cli.term:command"
81+
82+
Rules:
83+
84+
* **Built-ins win name collisions.** A plugin cannot shadow ``eval``,
85+
``config`` or any other command shipped here — it is logged and skipped.
86+
* **Broken plugins are skipped, not fatal.** An entry point that fails to
87+
import, or that resolves to something other than a ``click.Command``, logs a
88+
warning and is left out; ``nooa`` keeps working.
89+
* Plugins are registered in entry-point-name order, so ``nooa --help`` is
90+
stable regardless of install order.
91+
* **Keep imports lazy**, exactly as for in-repo commands: every registered
92+
entry point is loaded on *every* ``nooa`` invocation, including
93+
``nooa --help``, so heavy imports belong inside the command handler.
6594
"""
6695

6796
import importlib
97+
import logging
6898
import pkgutil
6999
from collections.abc import Iterator
100+
from importlib import metadata
70101

71102
import click
72103

104+
logger = logging.getLogger(__name__)
105+
106+
#: Entry-point group external packages use to contribute subcommands.
107+
PLUGIN_ENTRY_POINT_GROUP = "nooa_cli.commands"
73108

74-
def discover_commands() -> Iterator[tuple[str, click.Command]]:
75-
"""Yield (name, command) pairs from all command modules in this package.
109+
110+
def _builtin_commands() -> Iterator[tuple[str, click.Command]]:
111+
"""Yield (name, command) pairs from the command modules in this package.
76112
77113
Scans this directory for Python modules, imports each one, and looks
78114
for a ``command`` attribute that is a ``click.Command`` or ``click.Group``.
79115
80116
Modules starting with ``_`` are skipped (private / template files).
117+
118+
Unlike the plugin pass, a malformed module here raises: it is a bug in
119+
this repository and should be loud.
81120
"""
82121
package_path = __path__
83122
package_name = __name__
@@ -104,3 +143,66 @@ def discover_commands() -> Iterator[tuple[str, click.Command]]:
104143
name = getattr(module, "NAME", module_info.name)
105144

106145
yield name, cmd
146+
147+
148+
def _plugin_commands() -> Iterator[tuple[str, click.Command]]:
149+
"""Yield (name, command) pairs contributed by other installed packages.
150+
151+
Reads the ``nooa_cli.commands`` entry-point group. The entry-point name
152+
becomes the subcommand name, and ``ep.load()`` must return a
153+
``click.Command``.
154+
155+
Entry points are sorted by name so the command list does not depend on
156+
install order. Every failure is logged at WARNING and skipped: these are
157+
arbitrary third-party imports, and a broken one must never make ``nooa``
158+
unusable.
159+
"""
160+
try:
161+
eps = metadata.entry_points(group=PLUGIN_ENTRY_POINT_GROUP)
162+
except Exception: # noqa: BLE001 — importlib.metadata can raise on broken installs
163+
logger.warning(
164+
"Failed to enumerate %r entry points", PLUGIN_ENTRY_POINT_GROUP, exc_info=True
165+
)
166+
return
167+
168+
for ep in sorted(eps, key=lambda e: e.name):
169+
try:
170+
cmd = ep.load()
171+
except Exception: # noqa: BLE001 — third-party entry-point code, be defensive
172+
logger.warning("CLI plugin entry-point %r failed to load", ep.name, exc_info=True)
173+
continue
174+
175+
if not isinstance(cmd, click.Command):
176+
logger.warning(
177+
"CLI plugin entry-point %r resolved to %s, not a click.Command; skipping",
178+
ep.name,
179+
type(cmd).__name__,
180+
)
181+
continue
182+
183+
yield ep.name, cmd
184+
185+
186+
def discover_commands() -> Iterator[tuple[str, click.Command]]:
187+
"""Yield (name, command) pairs for every subcommand of ``nooa``.
188+
189+
Built-in commands from this package come first, then commands contributed
190+
by other packages through the ``nooa_cli.commands`` entry-point group.
191+
A plugin whose name is already taken is logged and skipped, so built-ins
192+
always win and each name is yielded exactly once.
193+
"""
194+
seen: set[str] = set()
195+
196+
for name, cmd in _builtin_commands():
197+
seen.add(name)
198+
yield name, cmd
199+
200+
for name, cmd in _plugin_commands():
201+
if name in seen:
202+
logger.warning(
203+
"CLI plugin entry-point %r shadows an existing command; ignoring the plugin",
204+
name,
205+
)
206+
continue
207+
seen.add(name)
208+
yield name, cmd

packages/nooa-cli/tests/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)