feat(plugins): catalog CLI surface — search/browse/info/install/update/doctor

- install: catalog names resolve to the pinned SHA (ref= checkout),
  print tier + capability summary before the enable prompt, and write a
  .hermes-catalog.json provenance sidecar; raw git URLs get a
  'custom (unreviewed) source' banner; --allow-removed loudly bypasses
  the removed blocklist (skip_removed_check=True)
- update: sidecar installs compare against the current catalog pin and
  force-reinstall at the new SHA (enabled state preserved); plain git
  installs keep the git-pull flow
- list: catalog:<tier>@<shaShort> annotation + red 'REMOVED from
  catalog' lines for blocklisted installs (table and --json)
- search/browse/info: live-index catalog tables and full entry detail
  with removed-list warnings
- validate: human ✓/✗ output, --json for CI, exit 0/1
- doctor: per-plugin manifest/enabled/load-error/env/requires_hermes/
  provenance/pin/removed diagnosis, compact table or single-name detail
This commit is contained in:
Teknium 2026-07-22 08:14:51 -07:00
parent 44624631bf
commit f1b30414d5
No known key found for this signature in database
2 changed files with 645 additions and 7 deletions

View file

@ -613,12 +613,98 @@ def _install_plugin_core(
return target, installed_manifest, installed_name
# ---------------------------------------------------------------------------
# Catalog integration helpers
# ---------------------------------------------------------------------------
_CATALOG_SIDECAR = ".hermes-catalog.json"
def _looks_like_catalog_name(identifier: str) -> bool:
"""True when *identifier* could be a catalog entry name (not a URL/shorthand)."""
if not identifier or "/" in identifier or "\\" in identifier:
return False
if identifier.startswith(("https://", "http://", "git@", "ssh://", "file://")):
return False
from hermes_cli.plugin_catalog import _NAME_RE
return bool(_NAME_RE.match(identifier))
def _get_live_catalog_entry(name: str):
"""Look up *name* in the live-refreshed catalog (falls back in-tree)."""
from hermes_cli.plugin_catalog import load_catalog_live
for entry in load_catalog_live():
if entry.name == name:
return entry
return None
def _catalog_install_identifier(entry) -> str:
"""Build the ``_install_plugin_core`` identifier for a catalog entry.
Uses the explicit ``#subdir`` fragment form understood by
:func:`_resolve_git_url` when the entry lives in a repo subdirectory.
"""
if entry.subdir:
return f"{entry.repo}#{entry.subdir}"
return entry.repo
def _write_catalog_sidecar(target: Path, entry) -> None:
"""Record catalog provenance in ``.hermes-catalog.json`` inside *target*.
The sidecar is how ``update``/``list``/``doctor`` know the plugin came
from the catalog (and at which pin).
"""
import datetime
sidecar = {
"catalog_name": entry.name,
"repo": entry.repo,
"sha": entry.sha,
"installed_at": datetime.datetime.now(datetime.timezone.utc)
.isoformat(timespec="seconds")
.replace("+00:00", "Z"),
"tier": entry.tier,
}
try:
(target / _CATALOG_SIDECAR).write_text(
json.dumps(sidecar, indent=2) + "\n", encoding="utf-8"
)
except OSError as exc:
logger.warning("Failed to write catalog sidecar in %s: %s", target, exc)
def _read_catalog_sidecar(plugin_dir: Path) -> Optional[dict]:
"""Return the parsed catalog provenance sidecar, or None."""
path = plugin_dir / _CATALOG_SIDECAR
if not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
# Unreadable / corrupt sidecar — treat as a non-catalog install.
return None
return data if isinstance(data, dict) else None
def cmd_install(
identifier: str,
force: bool = False,
enable: Optional[bool] = None,
allow_removed: bool = False,
) -> None:
"""Install a plugin from a Git URL or owner/repo shorthand.
"""Install a plugin from the catalog, a Git URL, or owner/repo shorthand.
When *identifier* matches a catalog entry name (and is not a URL or
``owner/repo`` shorthand), the install resolves to the entry's pinned
commit SHA and records catalog provenance in a ``.hermes-catalog.json``
sidecar. Raw git URLs keep the direct flow but are flagged as custom
(unreviewed) sources.
``allow_removed=True`` bypasses the removed-blocklist check (loudly).
After install, prompt "Enable now? [y/N]" unless *enable* is provided
(True = auto-enable without prompting, False = install disabled).
@ -627,6 +713,51 @@ def cmd_install(
console = Console()
entry = None
if _looks_like_catalog_name(identifier):
from hermes_cli.plugin_catalog import (
entry_capability_summary,
find_removed,
)
entry = _get_live_catalog_entry(identifier)
if entry is None:
console.print(
f"[red]Error:[/red] '{identifier}' is not in the Hermes "
"plugin catalog and is not a Git URL or owner/repo "
"shorthand.\n"
"Browse available entries with `hermes plugins search`."
)
sys.exit(1)
if not allow_removed:
removed = find_removed(entry.name) or find_removed(entry.repo)
if removed is not None:
try:
_raise_removed(removed)
except PluginOperationError as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
console.print(
f"[bold]{entry.name}[/bold] "
f"[cyan]\\[{entry.tier}][/cyan] "
f"[dim]pinned @ {entry.sha[:8]}[/dim]"
)
console.print(entry_capability_summary(entry))
identifier = _catalog_install_identifier(entry)
else:
console.print(
"[yellow]Warning:[/yellow] custom (unreviewed) source — "
"not from the Hermes catalog."
)
if allow_removed:
console.print(
"[bold red]WARNING:[/bold red] [red]--allow-removed set — "
"skipping the removed-plugin blocklist check. This plugin may "
"have been removed from the catalog for security reasons. "
"Proceed at your own risk.[/red]"
)
try:
git_url, _subdir = _resolve_git_url(identifier)
except ValueError as e:
@ -648,11 +779,16 @@ def cmd_install(
target, installed_manifest, installed_name = _install_plugin_core(
identifier,
force=force,
ref=entry.sha if entry is not None else None,
skip_removed_check=allow_removed,
)
except PluginOperationError as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
if entry is not None:
_write_catalog_sidecar(target, entry)
if not (target / "plugin.yaml").exists() and not (target / "plugin.yml").exists() and not (
target / "__init__.py"
).exists():
@ -700,7 +836,13 @@ def cmd_install(
def cmd_update(name: str) -> None:
"""Update an installed plugin by pulling latest from its git remote."""
"""Update an installed plugin.
Catalog installs (``.hermes-catalog.json`` sidecar present) are compared
against the current catalog pin: if the pinned SHA changed, the plugin is
force-reinstalled at the new pin (enabled state preserved). Plain git
installs keep the existing ``git pull`` behavior.
"""
from rich.console import Console
console = Console()
@ -712,6 +854,11 @@ def cmd_update(name: str) -> None:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
sidecar = _read_catalog_sidecar(target)
if sidecar is not None and sidecar.get("catalog_name"):
_update_catalog_plugin(name, target, sidecar, console)
return
if not (target / ".git").exists():
console.print(
f"[red]Error:[/red] Plugin '{name}' was not installed from git "
@ -739,6 +886,55 @@ def cmd_update(name: str) -> None:
console.print(f"[dim]{out}[/dim]")
def _update_catalog_plugin(name: str, target: Path, sidecar: dict, console) -> None:
"""Re-pin a catalog-installed plugin to the current catalog SHA."""
catalog_name = str(sidecar.get("catalog_name") or name)
entry = _get_live_catalog_entry(catalog_name)
if entry is None:
console.print(
f"[red]Error:[/red] Plugin '{catalog_name}' is no longer in the "
"catalog — it may have been removed. Check "
"`hermes plugins doctor` and the removed blocklist."
)
sys.exit(1)
installed_sha = str(sidecar.get("sha") or "").strip().lower()
if installed_sha == entry.sha:
console.print(
f"[green]✓[/green] Plugin [bold]{catalog_name}[/bold] is "
f"already at catalog pin ({entry.sha[:8]})."
)
return
console.print(
f"[dim]Updating {catalog_name} to catalog pin:[/dim] "
f"{installed_sha[:8] or '(unknown)'}{entry.sha[:8]}"
)
# Preserve enabled state across the force reinstall.
was_enabled = _get_enabled_set()
try:
new_target, _manifest, _installed_name = _install_plugin_core(
_catalog_install_identifier(entry),
force=True,
ref=entry.sha,
)
except PluginOperationError as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
_write_catalog_sidecar(new_target, entry)
# Restore the pre-update enabled/disabled state verbatim (the reinstall
# itself never touches it, but be explicit in case core ever does).
_save_enabled_set(was_enabled)
console.print(
f"[green]✓[/green] Plugin [bold]{catalog_name}[/bold] updated to "
f"{entry.sha[:8]}."
)
def cmd_remove(name: str) -> None:
"""Remove an installed plugin by name."""
from rich.console import Console
@ -1188,6 +1384,44 @@ def _filter_plugin_entries(entries: list, args: Any, enabled: set, disabled: set
return filtered
def _catalog_annotation(dir_path) -> Optional[str]:
"""Return ``catalog:<tier>@<shaShort>`` for a catalog install, else None."""
if not dir_path:
return None
try:
sidecar = _read_catalog_sidecar(Path(dir_path))
except Exception:
return None
if not sidecar or not sidecar.get("catalog_name"):
return None
tier = str(sidecar.get("tier") or "community")
sha = str(sidecar.get("sha") or "")
return f"catalog:{tier}@{sha[:8]}"
def _removed_annotation(name: str, dir_path) -> Optional[str]:
"""Return the removed-blocklist reason when *name* matches, else None."""
try:
from hermes_cli.plugin_catalog import find_removed
except Exception:
return None
candidates = [name]
if dir_path:
try:
sidecar = _read_catalog_sidecar(Path(dir_path))
except Exception:
sidecar = None
if sidecar:
candidates.extend(
str(v) for v in (sidecar.get("catalog_name"), sidecar.get("repo")) if v
)
for candidate in candidates:
removed = find_removed(candidate)
if removed is not None:
return removed.reason or "no reason recorded"
return None
def cmd_list(args: Any | None = None) -> None:
"""List all plugins (bundled + user) with enabled/disabled state."""
from rich.console import Console
@ -1205,16 +1439,22 @@ def cmd_list(args: Any | None = None) -> None:
entries = _filter_plugin_entries(entries, args, enabled, disabled)
if getattr(args, "json", False):
payload = [
{
payload = []
for name, version, description, source, _dir, key in entries:
row = {
"name": name,
"status": _plugin_status(name, enabled, disabled, key=key),
"version": str(version),
"description": description,
"source": source,
}
for name, version, description, source, _dir, key in entries
]
catalog = _catalog_annotation(_dir)
if catalog:
row["catalog"] = catalog
removed_reason = _removed_annotation(name, _dir)
if removed_reason is not None:
row["removed"] = removed_reason
payload.append(row)
print(json.dumps(payload, indent=2))
return
@ -1235,6 +1475,7 @@ def cmd_list(args: Any | None = None) -> None:
table.add_column("Description")
table.add_column("Source", style="dim")
removed_lines: list[str] = []
for name, version, description, source, _dir, key in entries:
status_name = _plugin_status(name, enabled, disabled, key=key)
if status_name == "disabled":
@ -1243,10 +1484,20 @@ def cmd_list(args: Any | None = None) -> None:
status = "[green]enabled[/green]"
else:
status = "[yellow]not enabled[/yellow]"
table.add_row(name, status, str(version), description, source)
catalog = _catalog_annotation(_dir)
source_label = f"{source} [cyan]{catalog}[/cyan]" if catalog else source
table.add_row(name, status, str(version), description, source_label)
removed_reason = _removed_annotation(name, _dir)
if removed_reason is not None:
removed_lines.append(
f"[red bold]✗ {name} — REMOVED from catalog: "
f"{removed_reason}[/red bold]"
)
console.print()
console.print(table)
for line in removed_lines:
console.print(line)
console.print()
console.print("[dim]Compact view:[/dim] hermes plugins list --plain --no-bundled")
console.print("[dim]Interactive toggle:[/dim] hermes plugins")
@ -1254,6 +1505,337 @@ def cmd_list(args: Any | None = None) -> None:
console.print("[dim]Plugins are opt-in by default — only 'enabled' plugins load.[/dim]")
# ---------------------------------------------------------------------------
# Catalog commands — search / browse / info / validate / doctor
# ---------------------------------------------------------------------------
def _entry_capability_counts(entry) -> str:
"""Compact capability summary like ``2 tools, 1 hook`` for table rows."""
caps = entry.capabilities
parts: list[str] = []
for count, singular in (
(len(caps.provides_tools), "tool"),
(len(caps.provides_hooks), "hook"),
(len(caps.provides_middleware), "middleware"),
):
if count:
plural = "" if count == 1 or singular == "middleware" else "s"
parts.append(f"{count} {singular}{plural}")
if caps.requires_env:
parts.append(f"{len(caps.requires_env)} env")
return ", ".join(parts) or ""
def _render_catalog_entries(entries, console) -> None:
"""Render catalog entries as the shared search/browse Rich table."""
from rich.table import Table
table = Table(title="Hermes Plugin Catalog", show_lines=False)
table.add_column("Name", style="bold")
table.add_column("Tier")
table.add_column("Description")
table.add_column("Pinned", style="dim")
table.add_column("Capabilities", style="dim")
for entry in entries:
tier = (
"[cyan]official[/cyan]"
if entry.tier == "official"
else "[magenta]community[/magenta]"
)
description = entry.description
if len(description) > 60:
description = description[:57] + "..."
table.add_row(
entry.name,
tier,
description,
entry.sha[:8],
_entry_capability_counts(entry),
)
console.print()
console.print(table)
console.print()
console.print("[dim]Details:[/dim] hermes plugins info <name>")
console.print("[dim]Install:[/dim] hermes plugins install <name>")
def cmd_search(query: str = "") -> None:
"""Search the plugin catalog (live index when reachable)."""
from rich.console import Console
from hermes_cli.plugin_catalog import filter_entries, load_catalog_live
console = Console()
entries = filter_entries(load_catalog_live(), query)
if not entries:
if query:
console.print(
f"[dim]No catalog entries match '{query}'. "
"Browse everything with `hermes plugins browse`.[/dim]"
)
else:
console.print("[dim]No catalog entries available.[/dim]")
return
_render_catalog_entries(entries, console)
def cmd_browse() -> None:
"""List every plugin catalog entry."""
cmd_search("")
def cmd_info(name: str) -> None:
"""Show the full catalog entry for *name*."""
from rich.console import Console
from hermes_cli.plugin_catalog import find_removed
console = Console()
entry = _get_live_catalog_entry(name)
if entry is None:
console.print(
f"[red]Error:[/red] '{name}' is not in the plugin catalog. "
"Browse entries with `hermes plugins search`."
)
sys.exit(1)
caps = entry.capabilities
console.print()
console.print(f"[bold]{entry.name}[/bold] [cyan]\\[{entry.tier}][/cyan]")
if entry.description:
console.print(entry.description)
console.print()
console.print(f"[dim]Repo:[/dim] {entry.repo}")
if entry.subdir:
console.print(f"[dim]Subdir:[/dim] {entry.subdir}")
console.print(f"[dim]Pinned SHA:[/dim] {entry.sha}")
console.print(f"[dim]Maintainer:[/dim] {entry.maintainer}")
if entry.requires_hermes:
console.print(f"[dim]Requires:[/dim] hermes {entry.requires_hermes}")
if entry.platforms:
console.print(f"[dim]Platforms:[/dim] {', '.join(entry.platforms)}")
if entry.docs_url:
console.print(f"[dim]Docs:[/dim] {entry.docs_url}")
console.print()
console.print(f"[dim]Tools:[/dim] {', '.join(caps.provides_tools) or '(none)'}")
console.print(f"[dim]Hooks:[/dim] {', '.join(caps.provides_hooks) or '(none)'}")
console.print(f"[dim]Middleware:[/dim] {', '.join(caps.provides_middleware) or '(none)'}")
console.print(f"[dim]Env vars:[/dim] {', '.join(caps.requires_env) or '(none)'}")
console.print()
removed = find_removed(entry.name) or find_removed(entry.repo)
if removed is not None:
detail = removed.reason or "no reason recorded"
if removed.date:
detail += f" (removed {removed.date})"
console.print(
f"[red bold]✗ REMOVED from catalog: {detail}[/red bold]"
)
console.print()
console.print(f"[dim]Install:[/dim] hermes plugins install {entry.name}")
console.print()
def cmd_validate(path: str, as_json: bool = False) -> None:
"""Validate a plugin directory for catalog admission. Exits 0/1."""
from rich.console import Console
from hermes_cli.plugin_validate import validate_plugin_dir
report = validate_plugin_dir(Path(path))
if as_json:
print(json.dumps(report.to_dict(), indent=2))
sys.exit(report.exit_code)
console = Console()
console.print()
for name, ok, detail in report.checks:
mark = "[green]✓[/green]" if ok else "[red]✗[/red]"
line = f"{mark} {name}"
if detail:
line += f" [dim]— {detail}[/dim]"
console.print(line)
for warning in report.warnings:
console.print(f"[yellow]⚠ {warning}[/yellow]")
console.print()
if report.ok:
console.print("[green bold]Validation passed.[/green bold]")
else:
console.print("[red bold]Validation failed.[/red bold]")
sys.exit(report.exit_code)
def _runtime_load_errors() -> dict[str, str]:
"""Return ``{plugin_key: error}`` from a fresh PluginManager scan.
Uses the idempotent ``discover_plugins()`` path so we don't need a full
agent boot; failures are non-fatal (doctor still reports what it can).
"""
try:
from hermes_cli.plugins import discover_plugins, get_plugin_manager
discover_plugins()
manager = get_plugin_manager()
return {
key: loaded.error
for key, loaded in manager._plugins.items()
if loaded.error
}
except Exception as exc:
logger.debug("doctor: runtime plugin scan failed: %s", exc)
return {}
def _doctor_plugin_report(name: str, dir_path: Path, key: str,
enabled: set, disabled: set,
load_errors: dict[str, str]) -> dict:
"""Collect doctor facts for one installed plugin directory."""
from hermes_cli.plugins import _running_hermes_version, _version_satisfies
manifest = _read_manifest(dir_path)
facts: dict[str, Any] = {
"name": name,
"manifest_ok": bool(manifest.get("name")),
"status": _plugin_status(name, enabled, disabled, key=key),
"load_error": load_errors.get(key) or load_errors.get(name) or "",
"missing_env": _missing_requires_env_names(manifest),
"requires_hermes": "",
"catalog": "",
"pin": "",
"removed": "",
}
spec = str(manifest.get("requires_hermes") or "").strip()
if spec:
current = _running_hermes_version()
if _version_satisfies(spec, current):
facts["requires_hermes"] = f"{spec}"
else:
facts["requires_hermes"] = f"{spec} ✗ (running {current})"
sidecar = _read_catalog_sidecar(dir_path)
if sidecar and sidecar.get("catalog_name"):
tier = str(sidecar.get("tier") or "community")
sha = str(sidecar.get("sha") or "")
facts["catalog"] = f"catalog:{tier}@{sha[:8]}"
entry = _get_live_catalog_entry(str(sidecar["catalog_name"]))
if entry is None:
facts["pin"] = "entry gone from catalog"
elif entry.sha != sha:
facts["pin"] = (
f"behind catalog pin ({sha[:8]}{entry.sha[:8]}) — "
"run `hermes plugins update`"
)
else:
facts["pin"] = "at catalog pin"
removed_reason = _removed_annotation(name, dir_path)
if removed_reason is not None:
facts["removed"] = removed_reason
return facts
def cmd_doctor(name: Optional[str] = None) -> None:
"""Diagnose installed plugins (or a single one when *name* given)."""
from rich.console import Console
from rich.table import Table
console = Console()
plugins_dir = _plugins_dir()
installed = [
(d.name, d) for d in sorted(plugins_dir.iterdir()) if d.is_dir()
]
if name is not None:
installed = [(n, d) for n, d in installed if n == name]
if not installed:
console.print(
f"[red]Error:[/red] Plugin '{name}' not found in {plugins_dir}."
)
sys.exit(1)
if not installed:
console.print("[dim]No plugins installed under[/dim] "
f"{plugins_dir}")
return
enabled = _get_enabled_set()
disabled = _get_disabled_set()
load_errors = _runtime_load_errors()
reports = [
_doctor_plugin_report(n, d, n, enabled, disabled, load_errors)
for n, d in installed
]
if name is not None:
facts = reports[0]
console.print()
console.print(f"[bold]{facts['name']}[/bold]")
console.print(
f"[dim]Manifest:[/dim] "
+ ("[green]ok[/green]" if facts["manifest_ok"]
else "[red]missing/invalid plugin.yaml[/red]")
)
console.print(f"[dim]Status:[/dim] {facts['status']}")
if facts["load_error"]:
console.print(f"[dim]Load error:[/dim] [red]{facts['load_error']}[/red]")
if facts["missing_env"]:
console.print(
f"[dim]Missing env:[/dim] [yellow]{', '.join(facts['missing_env'])}[/yellow]"
)
if facts["requires_hermes"]:
console.print(f"[dim]Requires:[/dim] hermes {facts['requires_hermes']}")
if facts["catalog"]:
console.print(f"[dim]Provenance:[/dim] {facts['catalog']}")
console.print(f"[dim]Pin:[/dim] {facts['pin']}")
if facts["removed"]:
console.print(
f"[red bold]✗ REMOVED from catalog: {facts['removed']}[/red bold]"
)
console.print()
return
table = Table(title="Plugin Doctor", show_lines=False)
table.add_column("Name", style="bold")
table.add_column("Manifest")
table.add_column("Status")
table.add_column("Issues")
table.add_column("Catalog", style="dim")
for facts in reports:
issues: list[str] = []
if facts["load_error"]:
issues.append(f"[red]{facts['load_error']}[/red]")
if facts["missing_env"]:
issues.append(
f"[yellow]missing env: {', '.join(facts['missing_env'])}[/yellow]"
)
if facts["requires_hermes"] and "" in facts["requires_hermes"]:
issues.append(f"[red]requires hermes {facts['requires_hermes']}[/red]")
if facts["removed"]:
issues.append(
f"[red bold]REMOVED from catalog: {facts['removed']}[/red bold]"
)
if facts["pin"] and "behind" in facts["pin"]:
issues.append(f"[yellow]{facts['pin']}[/yellow]")
table.add_row(
facts["name"],
"[green]ok[/green]" if facts["manifest_ok"] else "[red]bad[/red]",
facts["status"],
"\n".join(issues) or "[dim]—[/dim]",
facts["catalog"] or "[dim]—[/dim]",
)
console.print()
console.print(table)
console.print()
# ---------------------------------------------------------------------------
# Provider plugin discovery helpers
# ---------------------------------------------------------------------------
@ -2083,7 +2665,18 @@ def plugins_command(args) -> None:
args.identifier,
force=getattr(args, "force", False),
enable=enable_arg,
allow_removed=getattr(args, "allow_removed", False),
)
elif action == "search":
cmd_search(getattr(args, "query", "") or "")
elif action == "browse":
cmd_browse()
elif action == "info":
cmd_info(args.name)
elif action == "validate":
cmd_validate(args.path, as_json=getattr(args, "json", False))
elif action == "doctor":
cmd_doctor(getattr(args, "name", None))
elif action == "update":
cmd_update(args.name)
elif action in {"remove", "rm", "uninstall"}:

View file

@ -42,6 +42,51 @@ def build_plugins_parser(subparsers, *, cmd_plugins: Callable) -> None:
action="store_true",
help="Install disabled (skip confirmation prompt); enable later with `hermes plugins enable <name>`",
)
plugins_install.add_argument(
"--allow-removed",
action="store_true",
help="DANGEROUS: bypass the catalog removed-plugin blocklist check",
)
plugins_search = plugins_subparsers.add_parser(
"search", help="Search the Hermes plugin catalog"
)
plugins_search.add_argument(
"query",
nargs="?",
default="",
help="Substring to match against entry names, descriptions, and tools",
)
plugins_subparsers.add_parser(
"browse", help="Browse every plugin catalog entry"
)
plugins_info = plugins_subparsers.add_parser(
"info", help="Show full catalog details for an entry"
)
plugins_info.add_argument("name", help="Catalog entry name")
plugins_validate = plugins_subparsers.add_parser(
"validate",
help="Validate a plugin directory for catalog admission (CI gate)",
)
plugins_validate.add_argument("path", help="Path to the plugin directory")
plugins_validate.add_argument(
"--json",
action="store_true",
help="Print machine-readable JSON (for CI)",
)
plugins_doctor = plugins_subparsers.add_parser(
"doctor", help="Diagnose installed plugins"
)
plugins_doctor.add_argument(
"name",
nargs="?",
default=None,
help="Plugin name to inspect in detail (default: all installed)",
)
plugins_update = plugins_subparsers.add_parser(
"update", help="Pull latest changes for an installed plugin"