feat(remote): fetch service list and CLI options from server for --remote help

When using --remote -h, fetch available services from the server's /api/services endpoint instead of listing local services.
This commit is contained in:
Andy
2026-03-19 00:06:36 -06:00
parent 61ee5cb10a
commit 9d21d8a246
3 changed files with 92 additions and 9 deletions
+2
View File
@@ -516,12 +516,14 @@ class dl:
"--remote",
is_flag=True,
default=False,
is_eager=True,
help="Use a remote unshackle server instead of local service code.",
)
@click.option(
"--server",
type=str,
default=None,
is_eager=True,
help="Name of the remote server from remote_services config (if multiple configured).",
)
@click.pass_context
+27
View File
@@ -1,6 +1,7 @@
import logging
import re
import click
from aiohttp import web
from aiohttp_swagger3 import SwaggerDocs, SwaggerInfo, SwaggerUiSettings
@@ -187,6 +188,32 @@ async def services(request: web.Request) -> web.Response:
if hasattr(service_module, "cli") and hasattr(service_module.cli, "short_help"):
service_data["url"] = service_module.cli.short_help
if hasattr(service_module, "cli") and hasattr(service_module.cli, "params"):
cli_params = []
for param in service_module.cli.params:
param_info: dict = {"name": getattr(param, "name", None)}
if isinstance(param, click.Argument):
param_info["kind"] = "argument"
param_info["required"] = param.required
else:
param_info["kind"] = "option"
param_info["opts"] = list(param.opts) if hasattr(param, "opts") else []
param_info["is_flag"] = getattr(param, "is_flag", False)
default = param.default
if default is None:
pass
elif callable(default) or type(default).__name__ == "Sentinel":
default = None
elif hasattr(default, "name"):
default = default.name
elif not isinstance(default, (str, int, float, bool, list)):
default = str(default)
param_info["default"] = default
param_info["help"] = getattr(param, "help", None)
param_info["type"] = param.type.name if hasattr(param.type, "name") else str(param.type)
cli_params.append(param_info)
service_data["cli_params"] = cli_params
if service_module.__doc__:
service_data["help"] = service_module.__doc__.strip()
+63 -9
View File
@@ -25,22 +25,28 @@ _ALIASES = {tag: getattr(module, "ALIASES") for tag, module in _MODULES.items()}
class Services(click.MultiCommand):
"""Lazy-loaded command group of project services."""
_remote_services_cache: list[dict] | None = None
# Click-specific methods
def list_commands(self, ctx: click.Context) -> list[str]:
"""Returns a list of all available Services as command names for Click.
In remote mode, also includes service tags from remote_services config
so the user can use services that aren't installed locally.
In remote mode, fetches the service list from the remote server
so the user sees exactly what's available remotely.
"""
tags = Services.get_tags()
remote = ctx.params.get("remote") or (ctx.parent and ctx.parent.params.get("remote"))
if remote:
remote_services = Services._fetch_remote_services(ctx)
if remote_services is not None:
return [s["tag"] for s in remote_services]
tags = Services.get_tags()
for svc_cfg in config.remote_services.values():
for remote_tag in svc_cfg.get("services", {}).keys():
if remote_tag not in tags:
tags.append(remote_tag)
return tags
return tags
return Services.get_tags()
def get_command(self, ctx: click.Context, name: str) -> click.Command:
"""Load the Service and return the Click CLI method."""
@@ -48,7 +54,7 @@ class Services(click.MultiCommand):
remote = ctx.params.get("remote") or (ctx.parent and ctx.parent.params.get("remote"))
if remote:
return Services._make_remote_command(tag)
return Services._make_remote_command(tag, ctx)
try:
service = Services.load(tag)
@@ -66,21 +72,69 @@ class Services(click.MultiCommand):
raise click.ClickException(f"Service '{tag}' has no 'cli' method configured.")
@staticmethod
def _make_remote_command(tag: str) -> click.Command:
"""Create a synthetic Click command for a remote service."""
def _fetch_remote_services(ctx: click.Context) -> list[dict] | None:
"""Fetch the service list from the remote server (cached per process)."""
if Services._remote_services_cache is not None:
return Services._remote_services_cache
try:
from unshackle.core.remote_service import RemoteClient, resolve_server
@click.command(name=tag)
server_name = ctx.params.get("server")
server_url, api_key, _ = resolve_server(server_name)
client = RemoteClient(server_url, api_key)
result = client.get("/api/services")
Services._remote_services_cache = result.get("services", [])
return Services._remote_services_cache
except Exception:
return None
@staticmethod
def _make_remote_command(tag: str, ctx: click.Context) -> click.Command:
"""Create a Click command for a remote service with server-provided options."""
svc_info = Services._fetch_remote_service_info(tag, ctx)
short_help = svc_info.get("url") if svc_info else None
cli_params = svc_info.get("cli_params") if svc_info else None
@click.command(name=tag, short_help=short_help)
@click.argument("title", type=str)
@click.pass_context
def remote_cli(ctx: click.Context, title: str) -> object:
def remote_cli(ctx: click.Context, title: str, **kwargs: object) -> object:
from unshackle.core.remote_service import RemoteService, resolve_server
server_name = ctx.parent.params.get("server") if ctx.parent else None
server_url, api_key, services_config = resolve_server(server_name)
return RemoteService(ctx, tag, title, server_url, api_key, services_config)
if cli_params:
for param in cli_params:
if param.get("kind") == "option":
opts = param.get("opts", [f"--{param['name']}"])
kwargs: dict = {}
if param.get("is_flag"):
kwargs["is_flag"] = True
kwargs["default"] = param.get("default", False)
else:
kwargs["default"] = param.get("default")
kwargs["type"] = str
if param.get("help"):
kwargs["help"] = param["help"]
remote_cli = click.option(*opts, **kwargs)(remote_cli)
return remote_cli
@staticmethod
def _fetch_remote_service_info(tag: str, ctx: click.Context) -> dict | None:
"""Fetch service info for a specific service from the remote server."""
try:
services = Services._fetch_remote_services(ctx)
if services:
for svc in services:
if svc.get("tag") == tag:
return svc
except Exception:
pass
return None
# Methods intended to be used anywhere
@staticmethod