Skip to content

Commit 9dbe7ab

Browse files
committed
improve plugin CLI recovery UX
1 parent d6bfa72 commit 9dbe7ab

9 files changed

Lines changed: 443 additions & 28 deletions

File tree

architecture/cli.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ Plugin catalog commands use the same layering shape:
4545
| **Service** | Domain rules: package listing, compatibility checks, uv/pip install and uninstall commands, plugin discovery verification | `PluginCatalogService`, `PluginInstallService` |
4646
| **Repository** | File/cache I/O for catalog aliases and catalog documents | `PluginCatalogRepository` |
4747

48-
The built-in `nvidia` catalog points at `https://nvidia-nemo.github.io/DataDesignerPlugins/catalog/plugins.json`. `NVIDIA-NeMo/DataDesignerPlugins` defines the catalog format. Each catalog entry is an installable package with docs, install metadata, compatibility constraints, and one or more runtime plugins. Users install and uninstall packages, not individual runtime plugins. Commands that take a package name also accept the package alias from the `data-designer-{alias}` package-name pattern; for example, `data-designer-calculator` can be addressed as `calculator`.
48+
The built-in `nvidia` catalog points at `https://nvidia-nemo.github.io/DataDesignerPlugins/catalog/plugins.json`. `NVIDIA-NeMo/DataDesignerPlugins` defines the catalog format. Each catalog entry is an installable package with docs, install metadata, compatibility constraints, and one or more runtime plugins. Users install and uninstall packages, not individual runtime plugins. Commands that take a package name also accept the package alias from the `data-designer-{alias}` package-name pattern; for example, `data-designer-calculator` can be addressed as `calculator`. If a user passes a runtime plugin name where a package is required, the CLI reports the package that owns that runtime plugin.
4949

5050
### Generation Commands
5151

@@ -78,7 +78,7 @@ User invokes command (e.g., `data-designer config models`)
7878
```
7979
User invokes command (e.g., `data-designer plugin list`)
8080
→ Command function wires DATA_DESIGNER_HOME and catalog options
81-
→ PluginCatalogController resolves the catalog alias
81+
→ PluginCatalogController resolves the catalog alias and chooses table or narrow-terminal layout
8282
→ PluginCatalogService loads packages and filters out incompatible packages by default
8383
→ PluginCatalogRepository reads local config and cached/remote catalog JSON
8484
```

packages/data-designer/src/data_designer/cli/README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,7 @@ data-designer plugin info github
342342
# Install a plugin package from a catalog and verify Data Designer can discover its plugins
343343
data-designer plugin install github --yes
344344
345-
# Preview without changing the current environment
345+
# Preview without changing the current environment. Exits 1 if compatibility would block install.
346346
data-designer plugin install github --dry-run
347347
348348
# Uninstall a plugin package and verify Data Designer no longer discovers its plugins
@@ -356,7 +356,7 @@ data-designer plugin catalog add research https://github.com/acme/dd-plugins
356356
data-designer plugin catalog list
357357
data-designer plugin catalog remove research
358358
359-
# List installed runtime plugin entry points without importing plugin modules
359+
# List installed runtime plugins with package and version metadata
360360
data-designer plugin installed
361361
```
362362

@@ -367,6 +367,12 @@ packages (`data-designer`, `data-designer-config`, and `data-designer-engine`)
367367
are kept in place. This prevents a plugin dependency from upgrading,
368368
downgrading, or reinstalling Data Designer itself.
369369

370+
Runtime plugin names shown by `plugin list`, `plugin search`, and
371+
`plugin installed` identify the plugin entry points Data Designer can load.
372+
Install, uninstall, and info commands take the plugin package name or package
373+
alias. If a user passes a runtime plugin name to one of those package commands,
374+
the CLI points them to the owning package.
375+
370376
In an active virtual environment with a user `pyproject.toml`, `uv` uses
371377
`uv add` so the plugin package is recorded in the project. Otherwise the CLI
372378
installs into the current Python environment with `uv pip install` or `pip`.

packages/data-designer/src/data_designer/cli/commands/plugin.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,10 @@ def install_command(
129129
dry_run: bool = typer.Option(
130130
False,
131131
"--dry-run",
132-
help="Print the install plan without mutating the current environment.",
132+
help=(
133+
"Print the install plan without mutating the current environment. Exits 1 if compatibility would block "
134+
"install."
135+
),
133136
),
134137
) -> None:
135138
"""Install one Data Designer plugin package, then verify declared runtime entry points."""
@@ -194,7 +197,7 @@ def uninstall_command(
194197

195198

196199
def installed_command(ctx: typer.Context) -> None:
197-
"""List installed Data Designer runtime plugin entry points."""
200+
"""List installed Data Designer runtime plugins with package metadata."""
198201
_warn_if_parent_catalog_unused(ctx, "installed runtime plugins are discovered from the current Python environment")
199202
controller = PluginCatalogController(DATA_DESIGNER_HOME)
200203
controller.run_installed()

packages/data-designer/src/data_designer/cli/controllers/plugin_catalog_controller.py

Lines changed: 113 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77
from pathlib import Path
88

99
import typer
10+
from packaging.utils import canonicalize_name
1011
from pydantic import ValidationError
1112
from rich.style import Style
1213
from rich.table import Table
1314
from rich.text import Text
1415

1516
from data_designer.cli.plugin_catalog import (
17+
DATA_DESIGNER_PLUGIN_PACKAGE_PREFIX,
1618
DEFAULT_PLUGIN_CATALOG_ALIAS,
1719
PLUGIN_CATALOG_ALIAS_PATTERN,
1820
CompatibilityResult,
@@ -36,6 +38,8 @@
3638
)
3739
from data_designer.config.utils.constants import NordColor
3840

41+
NARROW_CATALOG_LAYOUT_WIDTH = 100
42+
3943

4044
class PluginCatalogController:
4145
"""Controller for plugin catalog browsing, alias management, and package workflows.
@@ -117,6 +121,7 @@ def run_info(
117121
catalog.alias,
118122
refresh=refresh,
119123
include_incompatible=True,
124+
command_name="info",
120125
)
121126
entry = package_entries[0]
122127
compatibility = self.catalog_service.evaluate_compatibility(entry)
@@ -176,6 +181,7 @@ def run_install(
176181
catalog.alias,
177182
refresh=refresh,
178183
include_incompatible=True,
184+
command_name="install",
179185
)
180186
entry = package_entries[0]
181187
compatibility = self.catalog_service.evaluate_compatibility(entry)
@@ -210,9 +216,9 @@ def run_install(
210216
if dry_run:
211217
if not compatibility.is_compatible:
212218
print_warning(
213-
"Dry run complete; no changes made. A real install would be blocked because compatibility "
214-
"checks failed."
219+
"Dry run complete; no changes made. Install would be blocked because compatibility checks failed."
215220
)
221+
raise typer.Exit(code=1)
216222
else:
217223
print_info("Dry run complete; no changes made")
218224
return
@@ -255,6 +261,7 @@ def run_uninstall(
255261
catalog.alias,
256262
refresh=refresh,
257263
include_incompatible=True,
264+
command_name="uninstall",
258265
)
259266
entry = package_entries[0]
260267

@@ -296,7 +303,7 @@ def run_uninstall(
296303
)
297304

298305
def run_installed(self) -> None:
299-
"""List installed runtime plugin entry points without importing plugin modules."""
306+
"""List installed runtime plugins without importing plugin modules."""
300307
print_header("Installed Data Designer Runtime Plugins")
301308
installed_plugins = self.catalog_service.list_installed_plugins()
302309
if not installed_plugins:
@@ -408,6 +415,7 @@ def _get_package_entries_or_exit(
408415
*,
409416
refresh: bool,
410417
include_incompatible: bool,
418+
command_name: str,
411419
) -> list[PluginCatalogEntry]:
412420
try:
413421
package_entries = self.catalog_service.get_package_entries(
@@ -421,9 +429,44 @@ def _get_package_entries_or_exit(
421429
raise typer.Exit(code=1)
422430
if not package_entries:
423431
print_error(f"Plugin package or alias {package_name!r} was not found in catalog {catalog_alias!r}")
432+
self._display_runtime_plugin_recovery_hint(
433+
package_name,
434+
catalog_alias,
435+
refresh=refresh,
436+
include_incompatible=include_incompatible,
437+
command_name=command_name,
438+
)
424439
raise typer.Exit(code=1)
425440
return package_entries
426441

442+
def _display_runtime_plugin_recovery_hint(
443+
self,
444+
package_name: str,
445+
catalog_alias: str,
446+
*,
447+
refresh: bool,
448+
include_incompatible: bool,
449+
command_name: str,
450+
) -> None:
451+
try:
452+
runtime_entries = self.catalog_service.get_runtime_plugin_entries(
453+
package_name,
454+
catalog_alias,
455+
refresh=refresh,
456+
include_incompatible=include_incompatible,
457+
)
458+
except (PluginCatalogError, OSError, ValueError):
459+
return
460+
461+
if not runtime_entries:
462+
return
463+
464+
entry = runtime_entries[0]
465+
package_alias = _package_alias(entry.package.name) or entry.package.name
466+
print_info(f"{package_name!r} is a runtime plugin exposed by plugin package {entry.package.name!r}.")
467+
command = _plugin_package_command(command_name, package_alias, catalog_alias)
468+
print_info(f"Use the package instead: {shlex.join(command)}")
469+
427470
def _display_empty_list_state(self, catalog_alias: str, *, include_incompatible: bool) -> None:
428471
if include_incompatible:
429472
print_warning("No plugin packages found")
@@ -460,8 +503,37 @@ def _display_empty_search_state(
460503
return
461504

462505
print_warning("No matching plugin packages found")
506+
suggestions = self._suggest_entries(query, catalog_alias, include_incompatible=include_incompatible)
507+
if suggestions:
508+
package_names = [
509+
package_entries[0].package.name
510+
for package_entries in self.catalog_service.group_entries_by_package(suggestions).values()
511+
]
512+
print_info(f"Closest package matches: {', '.join(package_names)}")
513+
print_info("Try fewer terms, a package alias, or a runtime plugin name.")
514+
515+
def _suggest_entries(
516+
self,
517+
query: str,
518+
catalog_alias: str,
519+
*,
520+
include_incompatible: bool,
521+
) -> list[PluginCatalogEntry]:
522+
try:
523+
return self.catalog_service.suggest_entries(
524+
query,
525+
catalog_alias,
526+
refresh=False,
527+
include_incompatible=include_incompatible,
528+
)
529+
except (PluginCatalogError, OSError, ValueError):
530+
return []
463531

464532
def _display_catalog_entries(self, entries: list[PluginCatalogEntry]) -> None:
533+
if _console_width() < NARROW_CATALOG_LAYOUT_WIDTH:
534+
self._display_catalog_entries_vertical(entries)
535+
return
536+
465537
table = Table(title="Catalog Plugin Packages", border_style=NordColor.NORD8.value)
466538
table.add_column("Package", style=NordColor.NORD14.value, no_wrap=True)
467539
table.add_column("Description", style=NordColor.NORD4.value)
@@ -482,15 +554,33 @@ def _display_catalog_entries(self, entries: list[PluginCatalogEntry]) -> None:
482554
)
483555
console.print(table)
484556

557+
def _display_catalog_entries_vertical(self, entries: list[PluginCatalogEntry]) -> None:
558+
for index, package_entries in enumerate(self.catalog_service.group_entries_by_package(entries).values()):
559+
entry = package_entries[0]
560+
compatibility = self.catalog_service.evaluate_compatibility(entry)
561+
docs_url = entry.docs.url if entry.docs is not None and entry.docs.url is not None else ""
562+
if index:
563+
console.print()
564+
console.print(Text(entry.package.name, style=f"bold {NordColor.NORD14.value}"))
565+
console.print(f" Description: {entry.description}")
566+
console.print(f" Runtime plugins: {_format_runtime_plugins(package_entries)}")
567+
console.print(f" Compatible: {'yes' if compatibility.is_compatible else 'no'}")
568+
if docs_url:
569+
console.print(f" Docs: {docs_url}")
570+
485571
@staticmethod
486572
def _display_installed_plugins(installed_plugins: list[InstalledPluginInfo]) -> None:
487573
table = Table(title="Installed Runtime Plugins", border_style=NordColor.NORD8.value)
488574
table.add_column("Runtime Plugin", style=NordColor.NORD14.value, no_wrap=True)
575+
table.add_column("Package", style=NordColor.NORD9.value, no_wrap=True)
576+
table.add_column("Version", style=NordColor.NORD13.value, no_wrap=True)
489577
table.add_column("Entry Point", style=NordColor.NORD4.value)
490578

491579
for plugin in installed_plugins:
492580
table.add_row(
493581
plugin.name,
582+
plugin.package_name or "",
583+
plugin.package_version or "",
494584
plugin.entry_point_value,
495585
)
496586
console.print(table)
@@ -526,6 +616,26 @@ def _format_runtime_plugins(entries: list[PluginCatalogEntry]) -> str:
526616
return ", ".join(f"{entry.name} ({entry.plugin_type.value})" for entry in entries)
527617

528618

619+
def _package_alias(package_name: str) -> str | None:
620+
canonical_package_name = canonicalize_name(package_name)
621+
if not canonical_package_name.startswith(DATA_DESIGNER_PLUGIN_PACKAGE_PREFIX):
622+
return None
623+
return canonical_package_name.removeprefix(DATA_DESIGNER_PLUGIN_PACKAGE_PREFIX)
624+
625+
626+
def _plugin_package_command(command_name: str, package_alias: str, catalog_alias: str) -> list[str]:
627+
command = ["data-designer", "plugin"]
628+
if catalog_alias != DEFAULT_PLUGIN_CATALOG_ALIAS:
629+
command.extend(["--catalog", catalog_alias])
630+
command.extend([command_name, package_alias])
631+
return command
632+
633+
634+
def _console_width() -> int:
635+
width = getattr(console, "width", None)
636+
return width if isinstance(width, int) else 120
637+
638+
529639
def _format_docs_link(docs_url: str | None) -> Text:
530640
if not docs_url:
531641
return Text("")

packages/data-designer/src/data_designer/cli/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ def _is_version_request(args: list[str]) -> bool:
175175
"installed": {
176176
"module": f"{_CMD}.plugin",
177177
"attr": "installed_command",
178-
"help": "List installed runtime plugin entry points",
178+
"help": "List installed runtime plugins and their packages",
179179
},
180180
}
181181
),

packages/data-designer/src/data_designer/cli/plugin_catalog.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,8 @@ class InstalledPluginInfo:
247247

248248
name: str
249249
entry_point_value: str
250+
package_name: str | None = None
251+
package_version: str | None = None
250252

251253

252254
def get_default_plugin_catalog_url() -> str:

0 commit comments

Comments
 (0)