from __future__ import annotations import contextlib import io from dataclasses import dataclass, field from typing import Any, Callable, Dict, List, Optional, Sequence from SYS import pipeline as ctx from SYS.models import PipelineStageContext from SYS.rich_display import capture_rich_output CmdletCallable = Callable[[Any, Sequence[str], Dict[str, Any]], int] @dataclass(slots=True) class CmdletRunResult: """Programmatic result for a single cmdlet invocation.""" name: str args: Sequence[str] exit_code: int = 0 emitted: List[Any] = field(default_factory=list) # Best-effort: cmdlets can publish tables/items via pipeline state even when # they don't emit pipeline items. result_table: Optional[Any] = None result_items: List[Any] = field(default_factory=list) result_subject: Optional[Any] = None stdout: str = "" stderr: str = "" error: Optional[str] = None def _normalize_cmd_name(name: str) -> str: return str(name or "").replace("_", "-").strip().lower() def resolve_cmdlet(cmd_name: str) -> Optional[CmdletCallable]: """Resolve a cmdlet callable by name from the registry (aliases supported).""" try: from SYS.cmdlet_catalog import ensure_registry_loaded ensure_registry_loaded() except Exception: pass try: import cmdlet as cmdlet_pkg return cmdlet_pkg.get(cmd_name) except Exception: return None def run_cmdlet( cmd: str | CmdletCallable, args: Sequence[str] | None, config: Dict[str, Any], *, piped: Any = None, isolate: bool = True, capture_output: bool = True, stage_index: int = 0, total_stages: int = 1, pipe_index: Optional[int] = None, worker_id: Optional[str] = None, ) -> CmdletRunResult: """Run a single cmdlet programmatically and return structured results. This is intended for TUI/webapp consumers that want cmdlet behavior without going through the interactive CLI loop. Notes: - When `isolate=True` (default) this runs inside `ctx.new_pipeline_state()` so global CLI pipeline state is not mutated. - Output capturing covers both normal `print()` and Rich output via `capture_rich_output()`. """ normalized_args: Sequence[str] = list(args or []) if isinstance(cmd, str): name = _normalize_cmd_name(cmd) cmd_fn = resolve_cmdlet(name) else: name = getattr(cmd, "__name__", "cmdlet") cmd_fn = cmd result = CmdletRunResult(name=name, args=normalized_args) if not callable(cmd_fn): result.exit_code = 1 result.error = f"Unknown command: {name}" result.stderr = result.error return result stage_ctx = PipelineStageContext( stage_index=int(stage_index), total_stages=int(total_stages), pipe_index=pipe_index, worker_id=worker_id, ) stdout_buffer = io.StringIO() stderr_buffer = io.StringIO() stage_text = " ".join([name, *list(normalized_args)]).strip() state_cm = ctx.new_pipeline_state() if isolate else contextlib.nullcontext() with state_cm: # Keep behavior predictable: start from a clean slate. try: ctx.reset() except Exception: pass try: ctx.set_stage_context(stage_ctx) except Exception: pass try: ctx.set_current_cmdlet_name(name) except Exception: pass try: ctx.set_current_stage_text(stage_text) except Exception: pass try: ctx.set_current_command_text(stage_text) except Exception: pass try: run_cm = ( capture_rich_output(stdout=stdout_buffer, stderr=stderr_buffer) if capture_output else contextlib.nullcontext() ) with run_cm: with ( contextlib.redirect_stdout(stdout_buffer) if capture_output else contextlib.nullcontext() ): with ( contextlib.redirect_stderr(stderr_buffer) if capture_output else contextlib.nullcontext() ): result.exit_code = int(cmd_fn(piped, list(normalized_args), config)) except Exception as exc: result.exit_code = 1 result.error = f"{type(exc).__name__}: {exc}" finally: result.stdout = stdout_buffer.getvalue() result.stderr = stderr_buffer.getvalue() # Prefer cmdlet emits (pipeline semantics). try: result.emitted = list(stage_ctx.emits or []) except Exception: result.emitted = [] # Mirror CLI behavior: if cmdlet emitted items and there is no overlay table, # make emitted items the last result items for downstream consumers. try: has_overlay = bool(ctx.get_display_table()) except Exception: has_overlay = False if result.emitted and not has_overlay: try: ctx.set_last_result_items_only(list(result.emitted)) except Exception: pass # Best-effort snapshot of visible results. try: result.result_table = ( ctx.get_display_table() or ctx.get_current_stage_table() or ctx.get_last_result_table() ) except Exception: result.result_table = None try: result.result_items = list(ctx.get_last_result_items() or []) except Exception: result.result_items = [] try: result.result_subject = ctx.get_last_result_subject() except Exception: result.result_subject = None # Cleanup stage-local markers. try: ctx.clear_current_stage_text() except Exception: pass try: ctx.clear_current_cmdlet_name() except Exception: pass try: ctx.clear_current_command_text() except Exception: pass try: ctx.set_stage_context(None) except Exception: pass return result