4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
480 lines
18 KiB
Python
480 lines
18 KiB
Python
"""Slash commands for CLI parity, delegating to the Click CLI via subprocess."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import json
|
|
import os
|
|
import shlex
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
from rich.console import Console
|
|
from rich.markup import escape
|
|
|
|
from core.agent_harness.session.terminal_access import (
|
|
session_terminal,
|
|
set_turn_outcome_hint,
|
|
)
|
|
from surfaces.interactive_shell.command_registry.suggestions import closest_choice
|
|
from surfaces.interactive_shell.command_registry.types import SlashCommand
|
|
from surfaces.interactive_shell.runtime import Session, TaskKind
|
|
from surfaces.interactive_shell.runtime.subprocess_runner import (
|
|
SYNTHETIC_TEST_TIMEOUT_SECONDS,
|
|
build_opensre_cli_argv,
|
|
start_background_cli_task,
|
|
)
|
|
from surfaces.interactive_shell.ui import DIM, ERROR, print_command_output
|
|
from surfaces.interactive_shell.utils.telemetry.turn_outcome import format_wizard_cli_outcome
|
|
|
|
_UPDATE_SUBPROCESS_TIMEOUT_SECONDS = 300
|
|
_BACKGROUND_TEST_SUBCOMMANDS = frozenset({"run", "synthetic", "cloudopsbench"})
|
|
_TEST_SUBCOMMANDS = ("list", "run", "synthetic", "cloudopsbench")
|
|
_TEST_PICKER_SELECTION_FILE_ENV = "OPENSRE_TEST_PICKER_SELECTION_FILE"
|
|
_PARENT_INTERACTIVE_SHELL_ENV = "OPENSRE_PARENT_INTERACTIVE_SHELL"
|
|
_HEADLESS_CLI_SUBPROCESS_TIMEOUT_SECONDS = 90.0
|
|
|
|
|
|
def publish_headless_slash_response(
|
|
session: Session,
|
|
*,
|
|
message: str,
|
|
ok: bool = True,
|
|
) -> None:
|
|
"""Pin an explicit slash reply for gateway/headless surfaces (wizards, setup)."""
|
|
session.complete_latest_record(
|
|
"slash",
|
|
response_text=message.strip(),
|
|
ok=ok,
|
|
slash_outcome="headless_guidance",
|
|
)
|
|
|
|
|
|
def _decode_subprocess_stream(value: str | bytes | None) -> str:
|
|
if value is None:
|
|
return ""
|
|
if isinstance(value, bytes):
|
|
return value.decode("utf-8", errors="replace")
|
|
return value
|
|
|
|
|
|
def _cli_command_succeeded(exit_code: int | None) -> bool:
|
|
return exit_code == 0
|
|
|
|
|
|
def run_cli_command(
|
|
console: Console,
|
|
args: list[str],
|
|
*,
|
|
session: Session | None = None,
|
|
subprocess_timeout: float | None = None,
|
|
capture_output: bool = False,
|
|
) -> bool:
|
|
"""Helper to delegate complex or interactive Click commands to a child process.
|
|
|
|
``subprocess_timeout`` caps how long ``subprocess.run`` waits before raising
|
|
:class:`~subprocess.TimeoutExpired`. Interactive flows use ``None`` so the
|
|
child can prompt as long as needed; callers that hit the network without a
|
|
TTY (like ``opensre update``) pass a bounded timeout.
|
|
|
|
``capture_output`` (default ``False``) makes the helper capture stdout/stderr
|
|
and replay them through ``console`` even without a timeout. Set this for
|
|
non-interactive delegated commands (e.g. ``opensre tests list``) so their
|
|
output appears inside the REPL buffer instead of bypassing ``console.print``
|
|
via the child's inherited stdout FD. Interactive commands like ``onboard``
|
|
must leave this ``False`` so the child's prompts stay attached to the real
|
|
TTY. Capture is also enabled automatically whenever a timeout is set.
|
|
|
|
**Return value:** Reports subprocess success for headless/gateway sessions
|
|
(``session`` with no terminal facet) so slash analytics can show failure.
|
|
On the interactive REPL, always returns ``True`` so delegated CLI failures
|
|
do not propagate to :func:`dispatch_slash` and exit the shell — failures
|
|
are still recorded via :meth:`Session.mark_latest`.
|
|
|
|
Ctrl+C sends :exc:`KeyboardInterrupt`, which subclasses :exc:`BaseException`
|
|
rather than :exc:`Exception`; it is handled here so the REPL survives and the
|
|
child process exits on SIGINT alongside the interrupted ``run`` call.
|
|
"""
|
|
console.print()
|
|
cmd = build_opensre_cli_argv(args)
|
|
headless = session is not None and session_terminal(session) is None
|
|
should_capture = capture_output or subprocess_timeout is not None or headless
|
|
if headless and subprocess_timeout is None:
|
|
subprocess_timeout = _HEADLESS_CLI_SUBPROCESS_TIMEOUT_SECONDS
|
|
child_env = os.environ.copy()
|
|
child_env[_PARENT_INTERACTIVE_SHELL_ENV] = "1"
|
|
if should_capture:
|
|
# Captured child stdout isn't a TTY, so force Rich colour there and parse
|
|
# it back in print_command_output — otherwise its styling would be lost.
|
|
child_env["FORCE_COLOR"] = "1"
|
|
exit_code: int | None = 0
|
|
try:
|
|
if should_capture:
|
|
captured_result = subprocess.run(
|
|
cmd,
|
|
check=False,
|
|
timeout=subprocess_timeout,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
env=child_env,
|
|
)
|
|
exit_code = captured_result.returncode
|
|
print_command_output(console, captured_result.stdout or "")
|
|
print_command_output(console, captured_result.stderr or "", style=ERROR)
|
|
if captured_result.returncode != 0:
|
|
console.print(
|
|
f"[{ERROR}]CLI command exited with non-zero code {captured_result.returncode}[/]"
|
|
)
|
|
else:
|
|
interactive_result = subprocess.run(cmd, check=False, env=child_env)
|
|
exit_code = interactive_result.returncode
|
|
if interactive_result.returncode != 0:
|
|
console.print(
|
|
f"[{ERROR}]CLI command exited with non-zero code {interactive_result.returncode}[/]"
|
|
)
|
|
except subprocess.TimeoutExpired as exc:
|
|
exit_code = None
|
|
print_command_output(console, _decode_subprocess_stream(exc.stdout))
|
|
print_command_output(console, _decode_subprocess_stream(exc.stderr), style=ERROR)
|
|
console.print(f"[{ERROR}]error:[/] CLI command timed out")
|
|
except KeyboardInterrupt:
|
|
exit_code = None
|
|
console.print(f"[{DIM}]CLI command cancelled (Ctrl+C).[/]")
|
|
except Exception as exc:
|
|
exit_code = None
|
|
console.print(f"[{ERROR}]error running CLI command:[/] {exc}")
|
|
console.print()
|
|
if session is not None and not should_capture:
|
|
set_turn_outcome_hint(session, format_wizard_cli_outcome(args, exit_code=exit_code))
|
|
ok = _cli_command_succeeded(exit_code)
|
|
if session is not None and not ok:
|
|
session.mark_latest(ok=False, kind="slash")
|
|
# Headless/gateway surfaces need the real exit status for slash analytics.
|
|
# Interactive REPL handlers must not return False to dispatch_slash on CLI
|
|
# failure — that would exit the shell (/exit is the only intentional False).
|
|
return ok if headless else True
|
|
|
|
|
|
def _cmd_onboard(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
|
|
if session_terminal(session) is None:
|
|
cli_cmd = " ".join(["uv run opensre onboard", *args]).strip()
|
|
message = (
|
|
"Onboarding is an interactive wizard (LLM provider, integrations, messaging). "
|
|
"It cannot run inside a Telegram chat.\n\n"
|
|
f"Run on the server:\n {cli_cmd}\n\n"
|
|
"Or configure individual services with "
|
|
"`/integrations setup <service>`."
|
|
)
|
|
console.print()
|
|
console.print(message)
|
|
publish_headless_slash_response(session, message=message)
|
|
return True
|
|
# The REPL loop treats ``/onboard`` as exclusive-stdin in
|
|
# ``runtime.utils.input_policy`` so the prompt_toolkit Application is torn down before
|
|
# this handler runs — the wizard subprocess therefore gets exclusive
|
|
# stdin and can drive its own interactive prompts without conflicting
|
|
# with the shell's UI.
|
|
return run_cli_command(console, ["onboard", *args], session=session)
|
|
|
|
|
|
def _cmd_auth(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
|
|
capture_output = not args or args[0].lower() in {"status", "logout"}
|
|
return run_cli_command(console, ["auth", *args], capture_output=capture_output, session=session)
|
|
|
|
|
|
def _cmd_login(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
|
|
return run_cli_command(console, ["auth", "login", *args], session=session)
|
|
|
|
|
|
def _cmd_remote(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
|
|
return run_cli_command(console, ["remote", *args], session=session)
|
|
|
|
|
|
def _catalog_task_kind(command: list[str]) -> TaskKind:
|
|
return TaskKind.SYNTHETIC_TEST if "synthetic" in command else TaskKind.CLI_COMMAND
|
|
|
|
|
|
def _argv_for_catalog_command(command: list[str]) -> list[str]:
|
|
if command[:1] == ["opensre"]:
|
|
return build_opensre_cli_argv(command[1:])
|
|
return command
|
|
|
|
|
|
def _start_test_command(
|
|
*,
|
|
session: Session,
|
|
console: Console,
|
|
command: list[str],
|
|
display_command: str | None = None,
|
|
) -> None:
|
|
shown = display_command or shlex.join(command)
|
|
session.record("cli_command", shown)
|
|
start_background_cli_task(
|
|
display_command=shown,
|
|
argv_list=_argv_for_catalog_command(command),
|
|
session=session,
|
|
console=console,
|
|
timeout_seconds=SYNTHETIC_TEST_TIMEOUT_SECONDS,
|
|
kind=_catalog_task_kind(command),
|
|
use_pty=True,
|
|
)
|
|
|
|
|
|
def _run_test_picker_for_background(session: Session, console: Console) -> bool:
|
|
console.print()
|
|
with contextlib.closing(
|
|
tempfile.NamedTemporaryFile(
|
|
prefix="opensre-test-selection-",
|
|
suffix=".json",
|
|
delete=False,
|
|
)
|
|
) as handle:
|
|
selection_path = Path(handle.name)
|
|
try:
|
|
env = dict(os.environ)
|
|
env[_TEST_PICKER_SELECTION_FILE_ENV] = str(selection_path)
|
|
result = subprocess.run(
|
|
build_opensre_cli_argv(["tests"]),
|
|
check=False,
|
|
env=env,
|
|
)
|
|
if result.returncode != 0:
|
|
console.print(f"[{ERROR}]CLI command exited with non-zero code {result.returncode}[/]")
|
|
console.print()
|
|
return True
|
|
if not selection_path.stat().st_size:
|
|
console.print()
|
|
return True
|
|
payload = json.loads(selection_path.read_text(encoding="utf-8"))
|
|
finally:
|
|
with contextlib.suppress(OSError):
|
|
selection_path.unlink()
|
|
|
|
if not isinstance(payload, list):
|
|
console.print(f"[{ERROR}]test picker returned an invalid selection[/]")
|
|
console.print()
|
|
return True
|
|
|
|
for item in payload:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
command = item.get("command")
|
|
if not isinstance(command, list) or not all(isinstance(part, str) for part in command):
|
|
continue
|
|
display = item.get("command_display")
|
|
_start_test_command(
|
|
session=session,
|
|
console=console,
|
|
command=command,
|
|
display_command=display if isinstance(display, str) else None,
|
|
)
|
|
console.print()
|
|
return True
|
|
|
|
|
|
def _cmd_tests(session: Session, console: Console, args: list[str]) -> bool:
|
|
if not args:
|
|
return _run_test_picker_for_background(session, console)
|
|
|
|
subcommand = args[0].lower()
|
|
if subcommand in _BACKGROUND_TEST_SUBCOMMANDS:
|
|
_start_test_command(
|
|
session=session,
|
|
console=console,
|
|
command=["opensre", "tests", *args],
|
|
)
|
|
return True
|
|
|
|
if subcommand.startswith("-"):
|
|
return run_cli_command(console, ["tests", *args], capture_output=True)
|
|
|
|
if subcommand not in _TEST_SUBCOMMANDS:
|
|
suggestion = closest_choice(subcommand, _TEST_SUBCOMMANDS)
|
|
if suggestion is None:
|
|
console.print(
|
|
f"[{ERROR}]unknown tests subcommand:[/] {escape(args[0])} "
|
|
"(try [bold]/tests list[/bold], [bold]/tests run <test_id>[/bold], "
|
|
"[bold]/tests synthetic[/bold], or [bold]/tests cloudopsbench[/bold])"
|
|
)
|
|
else:
|
|
console.print(
|
|
f"[{ERROR}]unknown tests subcommand:[/] {escape(args[0])} "
|
|
f"Did you mean [bold]/tests {suggestion}[/bold]?"
|
|
)
|
|
session.mark_latest(ok=False, kind="slash")
|
|
return True
|
|
|
|
return run_cli_command(console, ["tests", *args], capture_output=True)
|
|
|
|
|
|
def _cmd_guardrails(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
|
|
# ``opensre guardrails`` and its subcommands are all non-interactive printers
|
|
# (init/test/audit/rules just ``click.echo``). Capture so the output — and
|
|
# Click's usage block when no subcommand is given — reaches the REPL buffer
|
|
# instead of bypassing ``console.print`` via the child's inherited stdout FD.
|
|
return run_cli_command(console, ["guardrails", *args], capture_output=True)
|
|
|
|
|
|
def _cmd_update(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
|
|
return run_cli_command(
|
|
console,
|
|
["update", *args],
|
|
subprocess_timeout=_UPDATE_SUBPROCESS_TIMEOUT_SECONDS,
|
|
session=session,
|
|
)
|
|
|
|
|
|
def _cmd_uninstall(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
|
|
return run_cli_command(console, ["uninstall", *args])
|
|
|
|
|
|
def _cmd_config(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
|
|
# Non-interactive click.echo only; capture so output reaches the REPL buffer
|
|
# instead of the child's inherited stdout while prompt_toolkit redraws.
|
|
return run_cli_command(console, ["config", *args], capture_output=True)
|
|
|
|
|
|
def _cmd_messaging(session: Session, console: Console, args: list[str]) -> bool:
|
|
# Non-interactive subcommands: capture so output renders through the REPL
|
|
# (inherited stdout gets clipped by prompt_toolkit's screen management).
|
|
return run_cli_command(console, ["messaging", *args], capture_output=True, session=session)
|
|
|
|
|
|
def _cmd_hermes(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
|
|
return run_cli_command(console, ["hermes", *args])
|
|
|
|
|
|
def _cmd_cron(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
|
|
return run_cli_command(console, ["cron", *args])
|
|
|
|
|
|
def _cmd_watchdog(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
|
|
return run_cli_command(console, ["watchdog", *args])
|
|
|
|
|
|
def _cmd_debug(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
|
|
return run_cli_command(console, ["debug", *args])
|
|
|
|
|
|
def _cmd_misses(session: Session, console: Console, args: list[str]) -> bool: # noqa: ARG001
|
|
# Non-interactive printers only (list/stats/export/convert) — capture so the
|
|
# output reaches the REPL buffer instead of the child's inherited stdout.
|
|
return run_cli_command(console, ["misses", *args], capture_output=True)
|
|
|
|
|
|
COMMANDS: list[SlashCommand] = [
|
|
SlashCommand(
|
|
"/auth",
|
|
"Log in to LLM providers and inspect local auth state.",
|
|
_cmd_auth,
|
|
usage=("/auth", "/auth status", "/auth login deepseek", "/auth logout deepseek"),
|
|
),
|
|
SlashCommand(
|
|
"/login",
|
|
"Shortcut for LLM provider login.",
|
|
_cmd_login,
|
|
usage=("/login", "/login chatgpt", "/login claude", "/login deepseek"),
|
|
),
|
|
SlashCommand(
|
|
"/onboard",
|
|
"Run the interactive onboarding wizard.",
|
|
_cmd_onboard,
|
|
usage=("/onboard", "/onboard local_llm"),
|
|
),
|
|
SlashCommand(
|
|
"/remote",
|
|
"Connect to and trigger a remote deployed agent.",
|
|
_cmd_remote,
|
|
usage=(
|
|
"/remote health",
|
|
"/remote investigate",
|
|
"/remote ops",
|
|
"/remote pull",
|
|
"/remote trigger",
|
|
),
|
|
),
|
|
SlashCommand(
|
|
"/tests",
|
|
"Browse and run inventoried tests.",
|
|
_cmd_tests,
|
|
usage=("/tests", "/tests list", "/tests run", "/tests synthetic"),
|
|
first_arg_completions=tuple((name, f"/tests {name}") for name in _TEST_SUBCOMMANDS),
|
|
),
|
|
SlashCommand(
|
|
"/guardrails",
|
|
"Manage sensitive information guardrail rules.",
|
|
_cmd_guardrails,
|
|
usage=(
|
|
"/guardrails audit",
|
|
"/guardrails init",
|
|
"/guardrails rules",
|
|
"/guardrails test",
|
|
),
|
|
),
|
|
SlashCommand(
|
|
"/update",
|
|
"Check for a newer version and update if available.",
|
|
_cmd_update,
|
|
),
|
|
SlashCommand(
|
|
"/uninstall",
|
|
"Remove OpenSRE and all local data from this machine.",
|
|
_cmd_uninstall,
|
|
),
|
|
SlashCommand(
|
|
"/config",
|
|
"Show or edit local OpenSRE config.",
|
|
_cmd_config,
|
|
usage=("/config show", "/config set <key> <value>"),
|
|
),
|
|
SlashCommand(
|
|
"/messaging",
|
|
"Manage messaging security and identities.",
|
|
_cmd_messaging,
|
|
usage=(
|
|
"/messaging pair",
|
|
"/messaging allow",
|
|
"/messaging revoke",
|
|
"/messaging status",
|
|
),
|
|
),
|
|
SlashCommand(
|
|
"/hermes",
|
|
"Live-tail Hermes logs and send incidents to Telegram.",
|
|
_cmd_hermes,
|
|
usage=("/hermes watch",),
|
|
),
|
|
SlashCommand(
|
|
"/cron",
|
|
"Manage cron-driven scheduled deliveries.",
|
|
_cmd_cron,
|
|
usage=("/cron list", "/cron add", "/cron remove <id>", "/cron run <id>", "/cron logs <id>"),
|
|
),
|
|
SlashCommand(
|
|
"/watchdog",
|
|
"Monitor one process and send threshold alarms.",
|
|
_cmd_watchdog,
|
|
usage=("/watchdog --pid <pid> [--max-rss <size>] [--max-cpu <percent>]",),
|
|
examples=("/watchdog --pid 123 --max-rss 1G",),
|
|
),
|
|
SlashCommand(
|
|
"/debug",
|
|
"run targeted runtime diagnostics",
|
|
_cmd_debug,
|
|
),
|
|
SlashCommand(
|
|
"/misses",
|
|
"Triage investigation misses and export them as benchmark scenarios.",
|
|
_cmd_misses,
|
|
usage=(
|
|
"/misses list",
|
|
"/misses stats",
|
|
"/misses export --out <dir>",
|
|
"/misses convert <miss_id>",
|
|
),
|
|
),
|
|
]
|