mirror of
https://github.com/langgenius/dify.git
synced 2026-07-14 08:57:02 +08:00
590 lines
17 KiB
Python
590 lines
17 KiB
Python
"""Typer CLI for network-backed shellctl commands.
|
|
|
|
Job-management commands in this module intentionally stay on the SDK side of
|
|
the boundary: they parse CLI options, call `ShellctlClient`, and render compact
|
|
JSON. That keeps `shellctl --help` and `shellctl run --help` free of FastAPI,
|
|
SQLAlchemy, tmux, and local runtime bootstrap imports.
|
|
|
|
Only `serve` lazily imports server-side modules when that subcommand is
|
|
actually invoked.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from collections.abc import Awaitable, Callable
|
|
from pathlib import Path
|
|
from typing import NoReturn
|
|
|
|
import anyio
|
|
import httpx2 as httpx
|
|
import typer
|
|
from pydantic import BaseModel, ValidationError
|
|
|
|
from shellctl.client import ShellctlClient, ShellctlClientError
|
|
from shellctl.shared.constants import (
|
|
DEFAULT_AUTH_TOKEN_ENV,
|
|
DEFAULT_BASE_URL,
|
|
DEFAULT_BASE_URL_ENV,
|
|
DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS,
|
|
DEFAULT_GC_INTERVAL_SECONDS,
|
|
DEFAULT_IDLE_FLUSH_SECONDS,
|
|
DEFAULT_LIST_LIMIT,
|
|
DEFAULT_OUTPUT_LIMIT_BYTES,
|
|
DEFAULT_TERMINAL_COLS,
|
|
DEFAULT_TERMINAL_ROWS,
|
|
DEFAULT_TERMINATE_GRACE_SECONDS,
|
|
DEFAULT_TIMEOUT_SECONDS,
|
|
MAX_LIST_LIMIT,
|
|
MAX_OUTPUT_LIMIT_BYTES,
|
|
)
|
|
from shellctl.shared.schemas import (
|
|
DeleteJobResponse,
|
|
HealthResponse,
|
|
JobInfo,
|
|
JobResult,
|
|
JobStatusName,
|
|
JobStatusView,
|
|
RunJobRequest,
|
|
TerminalSize,
|
|
)
|
|
|
|
cli = typer.Typer(
|
|
no_args_is_help=True,
|
|
pretty_exceptions_enable=False,
|
|
rich_markup_mode=None,
|
|
)
|
|
|
|
|
|
@cli.command("health")
|
|
def health_command(
|
|
base_url: str = typer.Option(
|
|
DEFAULT_BASE_URL,
|
|
"--base-url",
|
|
envvar=DEFAULT_BASE_URL_ENV,
|
|
help="shellctl server base URL. You can also set SHELLCTL_BASE_URL.",
|
|
),
|
|
auth_token: str | None = typer.Option(
|
|
None,
|
|
"--auth-token",
|
|
envvar=DEFAULT_AUTH_TOKEN_ENV,
|
|
help="Accepted for CLI consistency but ignored because /healthz is public.",
|
|
),
|
|
) -> None:
|
|
"""Call the public health endpoint and report JSON."""
|
|
|
|
del auth_token
|
|
|
|
async def action(client: ShellctlClient) -> HealthResponse:
|
|
return await client.health()
|
|
|
|
_run_client_action(
|
|
base_url=base_url,
|
|
auth_token=None,
|
|
action=action,
|
|
emit=_emit_model,
|
|
)
|
|
|
|
|
|
@cli.command("run")
|
|
def run_command(
|
|
script: str = typer.Argument(...),
|
|
base_url: str = typer.Option(
|
|
DEFAULT_BASE_URL,
|
|
"--base-url",
|
|
envvar=DEFAULT_BASE_URL_ENV,
|
|
help="shellctl server base URL. You can also set SHELLCTL_BASE_URL.",
|
|
),
|
|
auth_token: str | None = typer.Option(
|
|
None,
|
|
"--auth-token",
|
|
envvar=DEFAULT_AUTH_TOKEN_ENV,
|
|
help=(
|
|
"Bearer token value. You can also set SHELLCTL_AUTH_TOKEN. "
|
|
"Leave it unset or empty when the server does not require auth."
|
|
),
|
|
),
|
|
cwd: Path | None = typer.Option(None, "--cwd"),
|
|
env: list[str] | None = typer.Option(None, "--env"),
|
|
timeout: float = typer.Option(DEFAULT_TIMEOUT_SECONDS, "--timeout"),
|
|
output_limit: int = typer.Option(DEFAULT_OUTPUT_LIMIT_BYTES, "--output-limit"),
|
|
idle_flush_seconds: float = typer.Option(
|
|
DEFAULT_IDLE_FLUSH_SECONDS,
|
|
"--idle-flush-seconds",
|
|
),
|
|
cols: int | None = typer.Option(None, "--cols"),
|
|
rows: int | None = typer.Option(None, "--rows"),
|
|
) -> None:
|
|
"""Create a job through the running shellctl server."""
|
|
|
|
request = _build_model(
|
|
RunJobRequest,
|
|
script=script,
|
|
cwd=str(cwd) if cwd is not None else None,
|
|
env=_parse_env(env),
|
|
terminal=_terminal_size(cols=cols, rows=rows),
|
|
timeout=timeout,
|
|
output_limit=output_limit,
|
|
idle_flush_seconds=idle_flush_seconds,
|
|
)
|
|
|
|
async def action(client: ShellctlClient) -> JobResult:
|
|
return await client.run(
|
|
request.script,
|
|
cwd=request.cwd,
|
|
env=request.env,
|
|
timeout=request.timeout,
|
|
terminal=request.terminal,
|
|
)
|
|
|
|
_run_client_action(
|
|
base_url=base_url,
|
|
auth_token=auth_token,
|
|
output_limit=output_limit,
|
|
idle_flush_seconds=idle_flush_seconds,
|
|
action=action,
|
|
emit=_emit_model,
|
|
)
|
|
|
|
|
|
@cli.command("wait")
|
|
def wait_command(
|
|
job_id: str = typer.Argument(...),
|
|
base_url: str = typer.Option(
|
|
DEFAULT_BASE_URL,
|
|
"--base-url",
|
|
envvar=DEFAULT_BASE_URL_ENV,
|
|
help="shellctl server base URL. You can also set SHELLCTL_BASE_URL.",
|
|
),
|
|
auth_token: str | None = typer.Option(
|
|
None,
|
|
"--auth-token",
|
|
envvar=DEFAULT_AUTH_TOKEN_ENV,
|
|
help=(
|
|
"Bearer token value. You can also set SHELLCTL_AUTH_TOKEN. "
|
|
"Leave it unset or empty when the server does not require auth."
|
|
),
|
|
),
|
|
offset: int = typer.Option(..., "--offset"),
|
|
timeout: float = typer.Option(DEFAULT_TIMEOUT_SECONDS, "--timeout"),
|
|
output_limit: int = typer.Option(DEFAULT_OUTPUT_LIMIT_BYTES, "--output-limit"),
|
|
idle_flush_seconds: float = typer.Option(
|
|
DEFAULT_IDLE_FLUSH_SECONDS,
|
|
"--idle-flush-seconds",
|
|
),
|
|
) -> None:
|
|
"""Wait for incremental output, completion, truncation, or timeout."""
|
|
|
|
async def action(client: ShellctlClient) -> JobResult:
|
|
return await client.wait(job_id, offset=offset, timeout=timeout)
|
|
|
|
_run_client_action(
|
|
base_url=base_url,
|
|
auth_token=auth_token,
|
|
output_limit=output_limit,
|
|
idle_flush_seconds=idle_flush_seconds,
|
|
action=action,
|
|
emit=_emit_model,
|
|
)
|
|
|
|
|
|
@cli.command("status")
|
|
def status_command(
|
|
job_id: str = typer.Argument(...),
|
|
base_url: str = typer.Option(
|
|
DEFAULT_BASE_URL,
|
|
"--base-url",
|
|
envvar=DEFAULT_BASE_URL_ENV,
|
|
help="shellctl server base URL. You can also set SHELLCTL_BASE_URL.",
|
|
),
|
|
auth_token: str | None = typer.Option(
|
|
None,
|
|
"--auth-token",
|
|
envvar=DEFAULT_AUTH_TOKEN_ENV,
|
|
help=(
|
|
"Bearer token value. You can also set SHELLCTL_AUTH_TOKEN. "
|
|
"Leave it unset or empty when the server does not require auth."
|
|
),
|
|
),
|
|
) -> None:
|
|
"""Materialize the current status view for one job."""
|
|
|
|
async def action(client: ShellctlClient) -> JobStatusView:
|
|
return await client.status(job_id)
|
|
|
|
_run_client_action(
|
|
base_url=base_url,
|
|
auth_token=auth_token,
|
|
action=action,
|
|
emit=_emit_model,
|
|
)
|
|
|
|
|
|
@cli.command("list")
|
|
def list_command(
|
|
base_url: str = typer.Option(
|
|
DEFAULT_BASE_URL,
|
|
"--base-url",
|
|
envvar=DEFAULT_BASE_URL_ENV,
|
|
help="shellctl server base URL. You can also set SHELLCTL_BASE_URL.",
|
|
),
|
|
auth_token: str | None = typer.Option(
|
|
None,
|
|
"--auth-token",
|
|
envvar=DEFAULT_AUTH_TOKEN_ENV,
|
|
help=(
|
|
"Bearer token value. You can also set SHELLCTL_AUTH_TOKEN. "
|
|
"Leave it unset or empty when the server does not require auth."
|
|
),
|
|
),
|
|
status: JobStatusName | None = typer.Option(None, "--status"),
|
|
limit: int = typer.Option(DEFAULT_LIST_LIMIT, "--limit", min=1, max=MAX_LIST_LIMIT),
|
|
) -> None:
|
|
"""List recent jobs, optionally filtered by lifecycle status."""
|
|
|
|
async def action(client: ShellctlClient) -> list[JobInfo]:
|
|
return await client.list_jobs(status=status, limit=limit)
|
|
|
|
_run_client_action(
|
|
base_url=base_url,
|
|
auth_token=auth_token,
|
|
action=action,
|
|
emit=_emit_job_list,
|
|
)
|
|
|
|
|
|
@cli.command("input")
|
|
def input_command(
|
|
job_id: str = typer.Argument(...),
|
|
text: str = typer.Argument(...),
|
|
base_url: str = typer.Option(
|
|
DEFAULT_BASE_URL,
|
|
"--base-url",
|
|
envvar=DEFAULT_BASE_URL_ENV,
|
|
help="shellctl server base URL. You can also set SHELLCTL_BASE_URL.",
|
|
),
|
|
auth_token: str | None = typer.Option(
|
|
None,
|
|
"--auth-token",
|
|
envvar=DEFAULT_AUTH_TOKEN_ENV,
|
|
help=(
|
|
"Bearer token value. You can also set SHELLCTL_AUTH_TOKEN. "
|
|
"Leave it unset or empty when the server does not require auth."
|
|
),
|
|
),
|
|
offset: int = typer.Option(..., "--offset"),
|
|
timeout: float = typer.Option(DEFAULT_TIMEOUT_SECONDS, "--timeout"),
|
|
output_limit: int = typer.Option(DEFAULT_OUTPUT_LIMIT_BYTES, "--output-limit"),
|
|
idle_flush_seconds: float = typer.Option(
|
|
DEFAULT_IDLE_FLUSH_SECONDS,
|
|
"--idle-flush-seconds",
|
|
),
|
|
) -> None:
|
|
"""Send text input to a running job and wait for the next result window."""
|
|
|
|
async def action(client: ShellctlClient) -> JobResult:
|
|
return await client.input(job_id, text, offset=offset, timeout=timeout)
|
|
|
|
_run_client_action(
|
|
base_url=base_url,
|
|
auth_token=auth_token,
|
|
output_limit=output_limit,
|
|
idle_flush_seconds=idle_flush_seconds,
|
|
action=action,
|
|
emit=_emit_model,
|
|
)
|
|
|
|
|
|
@cli.command("tail")
|
|
def tail_command(
|
|
job_id: str = typer.Argument(...),
|
|
base_url: str = typer.Option(
|
|
DEFAULT_BASE_URL,
|
|
"--base-url",
|
|
envvar=DEFAULT_BASE_URL_ENV,
|
|
help="shellctl server base URL. You can also set SHELLCTL_BASE_URL.",
|
|
),
|
|
auth_token: str | None = typer.Option(
|
|
None,
|
|
"--auth-token",
|
|
envvar=DEFAULT_AUTH_TOKEN_ENV,
|
|
help=(
|
|
"Bearer token value. You can also set SHELLCTL_AUTH_TOKEN. "
|
|
"Leave it unset or empty when the server does not require auth."
|
|
),
|
|
),
|
|
output_limit: int = typer.Option(
|
|
DEFAULT_OUTPUT_LIMIT_BYTES,
|
|
"--output-limit",
|
|
min=1,
|
|
max=MAX_OUTPUT_LIMIT_BYTES,
|
|
),
|
|
) -> None:
|
|
"""Read a UTF-8-safe output tail for one job."""
|
|
|
|
async def action(client: ShellctlClient) -> JobResult:
|
|
return await client.tail(job_id)
|
|
|
|
_run_client_action(
|
|
base_url=base_url,
|
|
auth_token=auth_token,
|
|
output_limit=output_limit,
|
|
action=action,
|
|
emit=_emit_model,
|
|
)
|
|
|
|
|
|
@cli.command("terminate")
|
|
def terminate_command(
|
|
job_id: str = typer.Argument(...),
|
|
base_url: str = typer.Option(
|
|
DEFAULT_BASE_URL,
|
|
"--base-url",
|
|
envvar=DEFAULT_BASE_URL_ENV,
|
|
help="shellctl server base URL. You can also set SHELLCTL_BASE_URL.",
|
|
),
|
|
auth_token: str | None = typer.Option(
|
|
None,
|
|
"--auth-token",
|
|
envvar=DEFAULT_AUTH_TOKEN_ENV,
|
|
help=(
|
|
"Bearer token value. You can also set SHELLCTL_AUTH_TOKEN. "
|
|
"Leave it unset or empty when the server does not require auth."
|
|
),
|
|
),
|
|
grace_seconds: float = typer.Option(
|
|
DEFAULT_TERMINATE_GRACE_SECONDS,
|
|
"--grace-seconds",
|
|
),
|
|
) -> None:
|
|
"""Terminate a job and return its materialized status."""
|
|
|
|
async def action(client: ShellctlClient) -> JobStatusView:
|
|
return await client.terminate(job_id, grace_seconds=grace_seconds)
|
|
|
|
_run_client_action(
|
|
base_url=base_url,
|
|
auth_token=auth_token,
|
|
action=action,
|
|
emit=_emit_model,
|
|
)
|
|
|
|
|
|
@cli.command("delete")
|
|
def delete_command(
|
|
job_id: str = typer.Argument(...),
|
|
base_url: str = typer.Option(
|
|
DEFAULT_BASE_URL,
|
|
"--base-url",
|
|
envvar=DEFAULT_BASE_URL_ENV,
|
|
help="shellctl server base URL. You can also set SHELLCTL_BASE_URL.",
|
|
),
|
|
auth_token: str | None = typer.Option(
|
|
None,
|
|
"--auth-token",
|
|
envvar=DEFAULT_AUTH_TOKEN_ENV,
|
|
help=(
|
|
"Bearer token value. You can also set SHELLCTL_AUTH_TOKEN. "
|
|
"Leave it unset or empty when the server does not require auth."
|
|
),
|
|
),
|
|
force: bool = typer.Option(False, "--force"),
|
|
grace_seconds: float = typer.Option(
|
|
DEFAULT_TERMINATE_GRACE_SECONDS,
|
|
"--grace-seconds",
|
|
),
|
|
) -> None:
|
|
"""Delete a job row and artifacts, optionally terminating first."""
|
|
|
|
async def action(client: ShellctlClient) -> DeleteJobResponse:
|
|
return await client.delete(
|
|
job_id,
|
|
force=force,
|
|
grace_seconds=grace_seconds,
|
|
)
|
|
|
|
_run_client_action(
|
|
base_url=base_url,
|
|
auth_token=auth_token,
|
|
action=action,
|
|
emit=_emit_model,
|
|
)
|
|
|
|
|
|
@cli.command("serve")
|
|
def serve_command(
|
|
listen: str = "127.0.0.1:8765",
|
|
auth_token: str | None = typer.Option(
|
|
None,
|
|
"--auth-token",
|
|
envvar=DEFAULT_AUTH_TOKEN_ENV,
|
|
help=(
|
|
"Bearer token value. You can also set SHELLCTL_AUTH_TOKEN. "
|
|
"Leave it unset or empty to disable HTTP bearer auth."
|
|
),
|
|
),
|
|
state_dir: Path | None = None,
|
|
runtime_dir: Path | None = None,
|
|
gc_interval_seconds: float = typer.Option(
|
|
DEFAULT_GC_INTERVAL_SECONDS,
|
|
"--gc-interval-seconds",
|
|
),
|
|
gc_finished_job_retention_seconds: float = typer.Option(
|
|
DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS,
|
|
"--gc-finished-job-retention-seconds",
|
|
),
|
|
) -> None:
|
|
"""Run the local shellctl FastAPI server via uvicorn."""
|
|
|
|
from shellctl.server.serve import (
|
|
serve_command as server_serve_command,
|
|
)
|
|
|
|
server_serve_command(
|
|
listen=listen,
|
|
auth_token=auth_token,
|
|
state_dir=state_dir,
|
|
runtime_dir=runtime_dir,
|
|
gc_interval_seconds=gc_interval_seconds,
|
|
gc_finished_job_retention_seconds=gc_finished_job_retention_seconds,
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
"""CLI entrypoint used by the console script and `python -m` invocations."""
|
|
|
|
cli()
|
|
|
|
|
|
def _parse_env(values: list[str] | None) -> dict[str, str] | None:
|
|
if not values:
|
|
return None
|
|
|
|
parsed: dict[str, str] = {}
|
|
for value in values:
|
|
if "=" not in value:
|
|
raise typer.BadParameter(
|
|
"env entries must use NAME=VALUE format",
|
|
param_hint="--env",
|
|
)
|
|
name, env_value = value.split("=", 1)
|
|
if not name:
|
|
raise typer.BadParameter(
|
|
"env names must be non-empty",
|
|
param_hint="--env",
|
|
)
|
|
parsed[name] = env_value
|
|
return parsed
|
|
|
|
|
|
def _terminal_size(*, cols: int | None, rows: int | None) -> TerminalSize | None:
|
|
if cols is None and rows is None:
|
|
return None
|
|
return _build_model(
|
|
TerminalSize,
|
|
cols=cols if cols is not None else DEFAULT_TERMINAL_COLS,
|
|
rows=rows if rows is not None else DEFAULT_TERMINAL_ROWS,
|
|
)
|
|
|
|
|
|
def _build_model[ModelT: BaseModel](
|
|
model_type: type[ModelT], /, **data: object
|
|
) -> ModelT:
|
|
try:
|
|
return model_type(**data)
|
|
except ValidationError as exc:
|
|
raise typer.BadParameter(_validation_error_message(exc)) from exc
|
|
|
|
|
|
async def _with_client[ResponseT](
|
|
base_url: str,
|
|
auth_token: str | None,
|
|
output_limit: int,
|
|
idle_flush_seconds: float,
|
|
action: Callable[[ShellctlClient], Awaitable[ResponseT]],
|
|
) -> ResponseT:
|
|
async with ShellctlClient(
|
|
base_url,
|
|
output_limit=output_limit,
|
|
idle_flush_seconds=idle_flush_seconds,
|
|
token=auth_token,
|
|
) as client:
|
|
return await action(client)
|
|
|
|
|
|
def _run_client_action[ResponseT](
|
|
*,
|
|
base_url: str,
|
|
auth_token: str | None,
|
|
action: Callable[[ShellctlClient], Awaitable[ResponseT]],
|
|
emit: Callable[[ResponseT], None],
|
|
output_limit: int = DEFAULT_OUTPUT_LIMIT_BYTES,
|
|
idle_flush_seconds: float = DEFAULT_IDLE_FLUSH_SECONDS,
|
|
) -> None:
|
|
try:
|
|
payload = anyio.run(
|
|
_with_client,
|
|
base_url,
|
|
auth_token,
|
|
output_limit,
|
|
idle_flush_seconds,
|
|
action,
|
|
)
|
|
except ShellctlClientError as exc:
|
|
_emit_error_and_exit(exc.code, exc.message)
|
|
except httpx.TimeoutException:
|
|
_emit_error_and_exit("request_timeout", "request timed out")
|
|
except httpx.TransportError as exc:
|
|
_emit_error_and_exit("connection_error", str(exc))
|
|
|
|
emit(payload)
|
|
|
|
|
|
def _emit_model(model: BaseModel) -> None:
|
|
typer.echo(model.model_dump_json(exclude_none=True), color=False)
|
|
|
|
|
|
def _emit_job_list(jobs: list[JobInfo]) -> None:
|
|
typer.echo(
|
|
json.dumps(
|
|
[item.model_dump(mode="json", exclude_none=True) for item in jobs],
|
|
separators=(",", ":"),
|
|
),
|
|
color=False,
|
|
)
|
|
|
|
|
|
def _emit_error_and_exit(code: str, message: str) -> NoReturn:
|
|
typer.echo(
|
|
json.dumps(
|
|
{"error": {"code": code, "message": message}},
|
|
separators=(",", ":"),
|
|
),
|
|
err=True,
|
|
color=False,
|
|
)
|
|
raise typer.Exit(code=1)
|
|
|
|
|
|
def _validation_error_message(exc: ValidationError) -> str:
|
|
detail = exc.errors(include_url=False)[0]
|
|
location = ".".join(str(part) for part in detail.get("loc", ()))
|
|
message = detail["msg"]
|
|
return f"{location}: {message}" if location else str(message)
|
|
|
|
|
|
__all__ = [
|
|
"cli",
|
|
"delete_command",
|
|
"health_command",
|
|
"input_command",
|
|
"list_command",
|
|
"main",
|
|
"run_command",
|
|
"serve_command",
|
|
"status_command",
|
|
"tail_command",
|
|
"terminate_command",
|
|
"wait_command",
|
|
]
|