Files
Medios-Macina/CLI.py
T

3110 lines
117 KiB
Python
Raw Normal View History

2025-11-25 20:09:33 -08:00
from __future__ import annotations
2025-12-20 02:12:45 -08:00
"""Medeia-Macina CLI.
2025-11-25 20:09:33 -08:00
2025-12-20 02:12:45 -08:00
This module intentionally uses a class-based architecture:
- no legacy procedural entrypoints
- no compatibility shims
- all REPL/pipeline/cmdlet execution state lives on objects
"""
2026-01-19 21:25:44 -08:00
# When running the CLI directly (not via the 'mm' launcher), honor the
# repository config `debug` flag by enabling `MM_DEBUG` so import-time
# diagnostics and bootstrap debug output are visible without setting the
# environment variable manually.
import os
from pathlib import Path
if not os.environ.get("MM_DEBUG"):
try:
2026-01-22 01:53:13 -08:00
# Check database first
db_path = Path(__file__).resolve().parent / "medios.db"
if db_path.exists():
import sqlite3
2026-01-22 11:05:40 -08:00
with sqlite3.connect(str(db_path), timeout=30.0) as conn:
2026-01-22 01:53:13 -08:00
cur = conn.cursor()
# Check for global debug key
cur.execute("SELECT value FROM config WHERE key = 'debug' AND category = 'global'")
row = cur.fetchone()
if row:
val = str(row[0]).strip().lower()
if val in ("1", "true", "yes", "on"):
os.environ["MM_DEBUG"] = "1"
2026-01-19 21:25:44 -08:00
except Exception:
pass
2025-11-25 20:09:33 -08:00
import json
2025-12-20 02:12:45 -08:00
import shlex
import sys
2025-12-11 12:47:30 -08:00
import threading
2025-12-20 02:12:45 -08:00
import time
import uuid
from copy import deepcopy
2026-01-19 03:14:30 -08:00
2025-12-20 02:12:45 -08:00
from pathlib import Path
2026-02-09 17:45:57 -08:00
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple, cast
2025-12-11 12:47:30 -08:00
2025-12-20 02:12:45 -08:00
import typer
from prompt_toolkit import PromptSession
from prompt_toolkit.completion import Completer, Completion
from prompt_toolkit.document import Document
from prompt_toolkit.styles import Style
2025-12-24 17:58:57 -08:00
from rich.console import Console
from rich.layout import Layout
from rich.panel import Panel
from rich.markdown import Markdown
from rich.bar import Bar
2026-01-18 10:50:42 -08:00
from rich.table import Table as RichTable
2026-01-11 02:26:39 -08:00
from SYS.rich_display import (
stderr_console,
stdout_console,
)
2026-03-25 22:39:30 -07:00
from cmdnat._status_shared import (
add_startup_check as _shared_add_startup_check,
collect_plugin_startup_checks as _collect_plugin_startup_checks,
2026-05-26 15:32:01 -07:00
has_provider as _has_provider,
2026-03-25 22:39:30 -07:00
)
2025-12-20 23:57:44 -08:00
def _install_rich_traceback(*, show_locals: bool = False) -> None:
"""Install Rich traceback handler as the default excepthook.
This keeps uncaught exceptions readable in the terminal.
"""
try:
from rich.traceback import install as rich_traceback_install
rich_traceback_install(show_locals=bool(show_locals))
except Exception:
# Fall back to the standard Python traceback if Rich isn't available.
return
# Default to Rich tracebacks for the whole process.
_install_rich_traceback(show_locals=False)
2025-12-20 02:12:45 -08:00
from SYS.logger import debug, set_debug
2026-05-24 12:32:57 -07:00
from SYS.repl_queue import clear_repl_state, pop_repl_commands, touch_repl_state
2025-12-20 02:12:45 -08:00
from SYS.worker_manager import WorkerManager
2025-11-25 20:09:33 -08:00
from SYS.cmdlet_catalog import (
2025-12-20 02:12:45 -08:00
get_cmdlet_arg_choices,
get_cmdlet_arg_flags,
get_cmdlet_metadata,
import_cmd_module,
list_cmdlet_metadata,
list_cmdlet_names,
2025-12-11 12:47:30 -08:00
)
2026-05-14 20:47:20 -07:00
from SYS.config import load_config
2026-01-18 10:50:42 -08:00
from SYS.result_table import Table
2026-01-19 03:14:30 -08:00
from SYS.worker import WorkerManagerRegistry, WorkerStages, WorkerOutputMirror, WorkerStageSession
from SYS.pipeline import PipelineExecutor
2026-05-21 16:19:17 -07:00
from PluginCore.registry import plugin_inline_query_choices
2025-11-25 20:09:33 -08:00
2026-01-03 03:37:48 -08:00
2026-01-18 10:50:42 -08:00
# Selection parsing and REPL lexer moved to SYS.cli_parsing
2026-02-09 17:45:57 -08:00
from SYS.cli_parsing import SelectionSyntax, SelectionFilterSyntax, MedeiaLexer
2025-12-20 02:12:45 -08:00
2026-01-18 10:50:42 -08:00
# SelectionFilterSyntax moved to SYS.cli_parsing (imported above)
2025-12-30 04:47:13 -08:00
2025-11-25 20:09:33 -08:00
2025-12-20 02:12:45 -08:00
2026-01-19 03:14:30 -08:00
2026-03-19 13:08:15 -07:00
2026-03-21 15:12:52 -07:00
def _send_mpv_ipc_command(
command: List[Any],
*,
ipc_path: Optional[str] = None,
timeout: float = 0.75,
wait_for_response: bool = True,
) -> bool:
2026-03-21 14:22:48 -07:00
if not isinstance(command, list) or not command:
2026-03-19 13:08:15 -07:00
return False
try:
2026-04-30 18:56:22 -07:00
from plugins.mpv.mpv_ipc import MPVIPCClient, get_ipc_pipe_path
2026-03-19 13:08:15 -07:00
client = MPVIPCClient(
2026-03-21 14:22:48 -07:00
socket_path=str(ipc_path or get_ipc_pipe_path()),
timeout=max(0.1, float(timeout or 0.75)),
2026-03-19 13:08:15 -07:00
silent=True,
)
try:
response = client.send_command({
2026-03-21 14:22:48 -07:00
"command": command,
2026-03-21 15:12:52 -07:00
}, wait=bool(wait_for_response))
2026-03-19 13:08:15 -07:00
finally:
try:
client.disconnect()
except Exception:
pass
2026-03-21 15:12:52 -07:00
if not wait_for_response:
return bool(response and (response.get("async") or response.get("request_id") is not None))
2026-03-19 13:08:15 -07:00
return bool(response and response.get("error") == "success")
except Exception as exc:
2026-03-21 14:22:48 -07:00
debug(f"mpv ipc command failed: {exc}")
2026-03-19 13:08:15 -07:00
return False
2026-03-21 14:22:48 -07:00
def _notify_mpv_osd(text: str, *, duration_ms: int = 3500, ipc_path: Optional[str] = None) -> bool:
message = str(text or "").strip()
if not message:
return False
return _send_mpv_ipc_command(
[
"show-text",
message,
max(0, int(duration_ms)),
],
ipc_path=ipc_path,
2026-03-21 15:12:52 -07:00
wait_for_response=False,
2026-03-21 14:22:48 -07:00
)
2026-03-21 15:12:52 -07:00
def _send_mpv_callback_event(metadata: Dict[str, Any], payload: Dict[str, Any]) -> bool:
2026-03-21 14:22:48 -07:00
callback = metadata.get("mpv_callback") if isinstance(metadata, dict) else None
if not isinstance(callback, dict):
return False
script_name = str(callback.get("script") or "").strip()
message_name = str(callback.get("message") or "").strip()
ipc_path = str(callback.get("ipc_path") or "").strip() or None
if not script_name or not message_name:
return False
2026-03-21 15:12:52 -07:00
event_payload = {
2026-03-21 14:22:48 -07:00
"kind": str(metadata.get("kind") or "").strip(),
}
2026-03-21 15:12:52 -07:00
if isinstance(payload, dict):
event_payload.update(payload)
2026-03-21 14:22:48 -07:00
return _send_mpv_ipc_command(
[
"script-message-to",
script_name,
message_name,
2026-03-21 15:12:52 -07:00
json.dumps(event_payload, ensure_ascii=False),
2026-03-21 14:22:48 -07:00
],
ipc_path=ipc_path,
2026-03-21 15:12:52 -07:00
wait_for_response=False,
2026-03-21 14:22:48 -07:00
)
2026-03-21 15:12:52 -07:00
def _notify_mpv_callback(metadata: Dict[str, Any], execution_result: Dict[str, Any]) -> bool:
return _send_mpv_callback_event(
metadata,
{
"phase": "completed",
"success": bool(execution_result.get("success")),
"status": str(execution_result.get("status") or "completed"),
"error": str(execution_result.get("error") or "").strip(),
"command_text": str(execution_result.get("command_text") or "").strip(),
},
)
def _build_mpv_progress_callback(metadata: Dict[str, Any]) -> Optional[Any]:
callback = metadata.get("mpv_callback") if isinstance(metadata, dict) else None
if not isinstance(callback, dict):
return None
last_sent_at: Dict[str, float] = {}
last_percent: Dict[str, int] = {}
last_text: Dict[str, str] = {}
def emit(payload: Dict[str, Any]) -> bool:
if not isinstance(payload, dict):
return False
event_name = str(payload.get("event") or "").strip().lower()
now = time.monotonic()
throttle_key = event_name or "progress"
if event_name == "pipe-percent":
pipe_index = int(payload.get("pipe_index") or 0)
percent = max(0, min(100, int(payload.get("percent") or 0)))
throttle_key = f"pipe-percent:{pipe_index}"
prev = last_percent.get(throttle_key)
if prev == percent:
return False
if prev is not None and percent < 100 and (percent - prev) < 5 and (now - last_sent_at.get(throttle_key, 0.0)) < 0.35:
return False
last_percent[throttle_key] = percent
payload = dict(payload)
payload["percent"] = percent
elif event_name == "transfer":
label = str(payload.get("label") or "transfer").strip() or "transfer"
throttle_key = f"transfer:{label}"
completed = payload.get("completed")
total = payload.get("total")
percent = None
try:
if total is not None and int(total) > 0 and completed is not None:
percent = max(0, min(100, int(round((int(completed) / max(1, int(total))) * 100.0))))
except Exception:
percent = None
if percent is not None:
prev = last_percent.get(throttle_key)
if prev == percent:
return False
if prev is not None and percent < 100 and (percent - prev) < 3 and (now - last_sent_at.get(throttle_key, 0.0)) < 0.35:
return False
last_percent[throttle_key] = percent
payload = dict(payload)
payload["percent"] = percent
elif event_name == "status":
pipe_index = int(payload.get("pipe_index") or 0)
throttle_key = f"status:{pipe_index}"
text = str(payload.get("text") or "").strip()
if last_text.get(throttle_key) == text and (now - last_sent_at.get(throttle_key, 0.0)) < 0.5:
return False
last_text[throttle_key] = text
last_sent_at[throttle_key] = now
event_payload = dict(payload)
event_payload.setdefault("phase", "progress")
return _send_mpv_callback_event(metadata, event_payload)
return emit
2026-03-19 13:08:15 -07:00
def _notify_mpv_completion(metadata: Dict[str, Any], execution_result: Dict[str, Any]) -> bool:
2026-03-21 14:22:48 -07:00
callback_sent = _notify_mpv_callback(metadata, execution_result)
2026-03-19 13:08:15 -07:00
notify = metadata.get("mpv_notify") if isinstance(metadata, dict) else None
if not isinstance(notify, dict):
2026-03-21 14:22:48 -07:00
return callback_sent
2026-03-19 13:08:15 -07:00
success = bool(execution_result.get("success"))
error_text = str(execution_result.get("error") or "").strip()
if success:
message = str(notify.get("success_text") or "").strip()
else:
failure_prefix = str(notify.get("failure_text") or "").strip()
message = failure_prefix
if error_text:
if message:
message = f"{message}: {error_text}"
else:
message = error_text
if not message:
2026-03-21 14:22:48 -07:00
return callback_sent
2026-03-19 13:08:15 -07:00
try:
duration_ms = int(notify.get("duration_ms") or 3500)
except Exception:
duration_ms = 3500
2026-03-21 14:22:48 -07:00
ipc_path = str(notify.get("ipc_path") or "").strip() or None
notified = _notify_mpv_osd(message, duration_ms=duration_ms, ipc_path=ipc_path)
return bool(callback_sent or notified)
2026-03-19 13:08:15 -07:00
2026-01-19 03:14:30 -08:00
class _OldWorkerStages:
2025-12-20 02:12:45 -08:00
"""Factory methods for stage/pipeline worker sessions."""
@staticmethod
def _start_worker_session(
worker_manager: Optional[WorkerManager],
*,
worker_type: str,
title: str,
description: str,
pipe_text: str,
config: Optional[Dict[str,
Any]],
2025-12-20 02:12:45 -08:00
completion_label: str,
error_label: str,
skip_logging_for: Optional[Set[str]] = None,
session_worker_ids: Optional[Set[str]] = None,
) -> Optional[WorkerStageSession]:
if worker_manager is None:
return None
if skip_logging_for and worker_type in skip_logging_for:
return None
safe_type = worker_type or "cmd"
worker_id = f"cli_{safe_type[:8]}_{uuid.uuid4().hex[:6]}"
try:
tracked = worker_manager.track_worker(
worker_id,
worker_type=worker_type,
title=title,
description=description or "(no args)",
pipe=pipe_text,
)
if not tracked:
return None
except Exception as exc:
print(f"[worker] Failed to track {worker_type}: {exc}", file=sys.stderr)
return None
if session_worker_ids is not None:
session_worker_ids.add(worker_id)
logging_enabled = False
try:
handler = worker_manager.enable_logging_for_worker(worker_id)
logging_enabled = handler is not None
except Exception:
logging_enabled = False
orig_stdout = sys.stdout
orig_stderr = sys.stderr
stdout_proxy = WorkerOutputMirror(
orig_stdout,
worker_manager,
worker_id,
"stdout"
)
stderr_proxy = WorkerOutputMirror(
orig_stderr,
worker_manager,
worker_id,
"stderr"
)
2025-12-20 02:12:45 -08:00
sys.stdout = stdout_proxy
sys.stderr = stderr_proxy
if isinstance(config, dict):
config["_current_worker_id"] = worker_id
try:
worker_manager.log_step(worker_id, f"Started {worker_type}")
except Exception:
pass
return WorkerStageSession(
manager=worker_manager,
worker_id=worker_id,
orig_stdout=orig_stdout,
orig_stderr=orig_stderr,
stdout_proxy=stdout_proxy,
stderr_proxy=stderr_proxy,
config=config,
logging_enabled=logging_enabled,
completion_label=completion_label,
error_label=error_label,
)
@classmethod
def begin_stage(
cls,
worker_manager: Optional[WorkerManager],
*,
cmd_name: str,
stage_tokens: Sequence[str],
config: Optional[Dict[str,
Any]],
2025-12-20 02:12:45 -08:00
command_text: str,
) -> Optional[WorkerStageSession]:
description = " ".join(stage_tokens[1:]
) if len(stage_tokens) > 1 else "(no args)"
2025-12-20 02:12:45 -08:00
session_worker_ids = None
if isinstance(config, dict):
session_worker_ids = config.get("_session_worker_ids")
return cls._start_worker_session(
worker_manager,
worker_type=cmd_name,
title=f"{cmd_name} stage",
description=description,
pipe_text=command_text,
config=config,
completion_label="Stage completed",
error_label="Stage error",
skip_logging_for={".worker",
"worker",
"workers"},
2025-12-20 02:12:45 -08:00
session_worker_ids=session_worker_ids,
)
@classmethod
def begin_pipeline(
cls,
worker_manager: Optional[WorkerManager],
*,
pipeline_text: str,
config: Optional[Dict[str,
Any]],
2025-12-20 02:12:45 -08:00
) -> Optional[WorkerStageSession]:
session_worker_ids: Set[str] = set()
if isinstance(config, dict):
config["_session_worker_ids"] = session_worker_ids
return cls._start_worker_session(
worker_manager,
worker_type="pipeline",
title="Pipeline run",
description=pipeline_text,
pipe_text=pipeline_text,
config=config,
completion_label="Pipeline completed",
error_label="Pipeline error",
session_worker_ids=session_worker_ids,
)
class CmdletIntrospection:
2025-12-20 02:12:45 -08:00
@staticmethod
2026-01-11 00:39:17 -08:00
def cmdlet_names(force: bool = False) -> List[str]:
2025-12-20 02:12:45 -08:00
try:
2026-01-11 00:39:17 -08:00
return list_cmdlet_names(force=force) or []
2025-12-16 01:45:01 -08:00
except Exception:
return []
2025-12-20 02:12:45 -08:00
@staticmethod
def cmdlet_args(cmd_name: str,
config: Optional[Dict[str,
Any]] = None) -> List[str]:
2025-12-16 01:45:01 -08:00
try:
2025-12-24 17:58:57 -08:00
return get_cmdlet_arg_flags(cmd_name, config=config) or []
2025-12-16 01:45:01 -08:00
except Exception:
2025-12-20 02:12:45 -08:00
return []
2025-12-16 01:45:01 -08:00
2025-12-20 02:12:45 -08:00
@staticmethod
2026-01-11 00:39:17 -08:00
def store_choices(config: Dict[str, Any], force: bool = False) -> List[str]:
2025-12-20 02:12:45 -08:00
try:
2026-01-09 01:22:06 -08:00
# Use the cached startup check from SharedArgs
2026-05-26 15:32:01 -07:00
from SYS.cmdlet_spec import SharedArgs
2026-01-11 00:39:17 -08:00
return SharedArgs.get_store_choices(config, force=force)
2025-12-20 02:12:45 -08:00
except Exception:
return []
@classmethod
def arg_choices(cls,
*,
cmd_name: str,
arg_name: str,
config: Dict[str,
2026-01-11 00:39:17 -08:00
Any],
force: bool = False) -> List[str]:
2025-12-20 02:12:45 -08:00
try:
normalized_arg = (arg_name or "").lstrip("-").strip().lower()
if normalized_arg in ("storage", "store"):
2026-01-16 03:25:36 -08:00
# Use cached/lightweight names for completions to avoid instantiating backends
2026-01-22 01:53:13 -08:00
# (instantiating backends may perform heavy initialization).
2026-01-16 03:25:36 -08:00
backends = cls.store_choices(config, force=False)
2025-12-20 02:12:45 -08:00
if backends:
return backends
if normalized_arg == "plugin":
2025-12-20 02:12:45 -08:00
canonical_cmd = (cmd_name or "").replace("_", "-").lower()
try:
2026-05-21 16:19:17 -07:00
from PluginCore.registry import (
2026-05-04 15:58:24 -07:00
list_configured_plugin_names_with_capability,
2026-05-24 12:32:57 -07:00
list_plugin_names_with_capability,
2026-05-04 15:58:24 -07:00
list_plugin_names_for_cmdlet,
)
2025-12-20 02:12:45 -08:00
except Exception:
2026-05-03 21:20:05 -07:00
list_configured_plugin_names_with_capability = None # type: ignore
2026-05-24 12:32:57 -07:00
list_plugin_names_with_capability = None # type: ignore
2026-05-04 15:58:24 -07:00
list_plugin_names_for_cmdlet = None # type: ignore
2025-12-20 02:12:45 -08:00
plugin_choices: List[str] = []
2025-12-20 02:12:45 -08:00
2026-05-24 12:32:57 -07:00
def _merge_choice_groups(*groups: Sequence[str]) -> List[str]:
seen: Set[str] = set()
merged: List[str] = []
for group in groups:
for entry in group or []:
key = str(entry or "").strip().lower()
if not key or key in seen:
continue
seen.add(key)
merged.append(str(entry))
return merged
if canonical_cmd == "file" and list_plugin_names_for_cmdlet is not None:
configured_add = list_plugin_names_for_cmdlet(
"add-file",
config,
configured_only=True,
) or []
available_add = list_plugin_names_for_cmdlet(
"add-file",
config,
configured_only=False,
) or []
configured_search = list_plugin_names_for_cmdlet(
"search-file",
config,
configured_only=True,
) or []
available_search = list_plugin_names_for_cmdlet(
"search-file",
config,
configured_only=False,
) or []
plugin_choices = _merge_choice_groups(
configured_add,
available_add,
configured_search,
available_search,
)
elif list_plugin_names_for_cmdlet is not None:
2026-05-04 15:58:24 -07:00
configured = list_plugin_names_for_cmdlet(
canonical_cmd,
config,
configured_only=True,
) or []
available = list_plugin_names_for_cmdlet(
canonical_cmd,
config,
configured_only=False,
) or []
# Prefer configured plugins first, but still show valid plugin options.
2026-05-24 12:32:57 -07:00
plugin_choices = _merge_choice_groups(configured, available)
2026-05-04 15:58:24 -07:00
elif canonical_cmd in {"add-file"} and list_configured_plugin_names_with_capability is not None:
plugin_choices = list_configured_plugin_names_with_capability("upload", config) or []
elif list_configured_plugin_names_with_capability is not None:
2026-05-03 21:20:05 -07:00
plugin_choices = list_configured_plugin_names_with_capability("search", config) or []
2025-12-20 02:12:45 -08:00
if plugin_choices:
return plugin_choices
2025-12-20 02:12:45 -08:00
if normalized_arg == "scrape":
try:
2026-05-21 16:19:17 -07:00
from plugins.metadata_plugin import list_metadata_plugins
2025-12-29 17:05:03 -08:00
metadata_plugins = list_metadata_plugins(config) or {}
if metadata_plugins:
return sorted(metadata_plugins.keys())
2025-12-20 02:12:45 -08:00
except Exception:
pass
return get_cmdlet_arg_choices(cmd_name, arg_name) or []
except Exception:
return []
@staticmethod
def query_args(cmd_name: str,
config: Optional[Dict[str,
Any]] = None) -> List[Dict[str,
Any]]:
try:
meta = get_cmdlet_metadata(cmd_name, config=config) or {}
except Exception:
return []
args = meta.get("args", []) if isinstance(meta, dict) else []
if not isinstance(args, list):
return []
query_args: List[Dict[str, Any]] = []
for arg in args:
if not isinstance(arg, dict):
continue
key = str(arg.get("query_key") or "").strip().lower()
aliases = [
str(value).strip().lower()
for value in (arg.get("query_aliases") or [])
if str(value).strip()
]
if not key and not aliases:
continue
query_args.append(arg)
return query_args
2026-05-24 12:32:57 -07:00
@staticmethod
def plugin_names_for_cmdlet(
cmd_name: str,
config: Optional[Dict[str, Any]] = None,
*,
configured_only: bool = False,
) -> List[str]:
try:
from PluginCore.registry import list_plugin_names_for_cmdlet
return list_plugin_names_for_cmdlet(
cmd_name,
config,
configured_only=configured_only,
) or []
except Exception:
return []
2025-12-20 02:12:45 -08:00
class CmdletCompleter(Completer):
"""Prompt-toolkit completer for the Medeia cmdlet REPL."""
2026-04-26 15:08:35 -07:00
_CMDLET_NAME_REFRESH_SECONDS = 2.0
2026-05-24 12:32:57 -07:00
_FILE_STAGE_ACTION_CMDLETS: Tuple[Tuple[str, str], ...] = (
("search", "search-file"),
("add", "add-file"),
("delete", "delete-file"),
("merge", "merge-file"),
("download", "download-file"),
("convert", "convert-file"),
("trim", "trim-file"),
("archive", "archive-file"),
("screenshot", "screen-shot"),
)
2026-04-26 15:08:35 -07:00
2025-12-20 02:12:45 -08:00
def __init__(self, *, config_loader: "ConfigLoader") -> None:
self._config_loader = config_loader
self.cmdlet_names = CmdletIntrospection.cmdlet_names()
2026-04-26 15:08:35 -07:00
self._cmdlet_names_refreshed_at = time.monotonic()
self._cmdlet_args_cache: Dict[Tuple[str, int], List[str]] = {}
self._query_args_cache: Dict[Tuple[str, int], List[Dict[str, Any]]] = {}
self._arg_choices_cache: Dict[Tuple[str, str, int], List[str]] = {}
self._inline_query_choices_cache: Dict[Tuple[str, str, int], List[str]] = {}
2026-05-24 12:32:57 -07:00
self._plugins_for_cmdlet_cache: Dict[Tuple[str, int, bool], List[str]] = {}
2026-04-26 15:08:35 -07:00
def _refresh_cmdlet_names(self) -> None:
now = time.monotonic()
if self.cmdlet_names and (now - self._cmdlet_names_refreshed_at) < self._CMDLET_NAME_REFRESH_SECONDS:
return
self.cmdlet_names = CmdletIntrospection.cmdlet_names(force=False)
self._cmdlet_names_refreshed_at = now
2025-12-20 02:12:45 -08:00
2025-12-20 23:57:44 -08:00
@staticmethod
2026-04-26 15:08:35 -07:00
def _config_cache_key(config: Dict[str, Any]) -> int:
return id(config) if isinstance(config, dict) else 0
def _cmdlet_args(self, cmd_name: str, config: Dict[str, Any]) -> List[str]:
key = (str(cmd_name or "").lower(), self._config_cache_key(config))
cached = self._cmdlet_args_cache.get(key)
if cached is not None:
return cached
value = CmdletIntrospection.cmdlet_args(cmd_name, config)
self._cmdlet_args_cache[key] = value
return value
def _query_args(self, cmd_name: str, config: Dict[str, Any]) -> List[Dict[str, Any]]:
key = (str(cmd_name or "").lower(), self._config_cache_key(config))
cached = self._query_args_cache.get(key)
if cached is not None:
return cached
value = CmdletIntrospection.query_args(cmd_name, config)
self._query_args_cache[key] = value
return value
def _arg_choices(
self,
*,
cmd_name: str,
arg_name: str,
config: Dict[str, Any],
force: bool = False,
) -> List[str]:
key = (
str(cmd_name or "").lower(),
str(arg_name or "").lower(),
self._config_cache_key(config),
)
if not force:
cached = self._arg_choices_cache.get(key)
if cached is not None:
return cached
value = CmdletIntrospection.arg_choices(
cmd_name=cmd_name,
arg_name=arg_name,
config=config,
force=force,
)
self._arg_choices_cache[key] = value
return value
def _inline_query_choices(
self,
provider_name: str,
field_name: str,
config: Dict[str, Any],
) -> List[str]:
key = (
str(provider_name or "").lower(),
str(field_name or "").lower(),
self._config_cache_key(config),
)
cached = self._inline_query_choices_cache.get(key)
if cached is not None:
return cached
value = plugin_inline_query_choices(provider_name, field_name, config)
self._inline_query_choices_cache[key] = value
return value
2026-05-24 12:32:57 -07:00
def _plugins_for_cmdlet(
self,
cmd_name: str,
config: Dict[str, Any],
*,
configured_only: bool = False,
) -> List[str]:
key = (
str(cmd_name or "").lower(),
self._config_cache_key(config),
bool(configured_only),
)
cached = self._plugins_for_cmdlet_cache.get(key)
if cached is not None:
return cached
value = CmdletIntrospection.plugin_names_for_cmdlet(
cmd_name,
config,
configured_only=configured_only,
)
self._plugins_for_cmdlet_cache[key] = value
return value
2025-12-29 17:05:03 -08:00
def _used_arg_logicals(
self,
cmd_name: str,
stage_tokens: List[str],
config: Dict[str,
Any]
2025-12-29 17:05:03 -08:00
) -> Set[str]:
2025-12-20 23:57:44 -08:00
"""Return logical argument names already used in this cmdlet stage.
2026-01-01 20:37:27 -08:00
Example: if the user has typed `download-file -url ...`, then `url`
2025-12-20 23:57:44 -08:00
is considered used and should not be suggested again (even as `--url`).
"""
2026-04-26 15:08:35 -07:00
arg_flags = self._cmdlet_args(cmd_name, config)
allowed = {a.lstrip("-").strip().lower()
for a in arg_flags if a}
2025-12-20 23:57:44 -08:00
if not allowed:
return set()
used: Set[str] = set()
for tok in stage_tokens[1:]:
if not tok or not tok.startswith("-"):
continue
if tok in {"-",
"--"}:
2025-12-20 23:57:44 -08:00
continue
# Handle common `-arg=value` form.
raw = tok.split("=", 1)[0]
logical = raw.lstrip("-").strip().lower()
if logical and logical in allowed:
used.add(logical)
return used
@staticmethod
def _flag_value(tokens: Sequence[str], *flags: str) -> Optional[str]:
want = {str(f).strip().lower() for f in flags if str(f).strip()}
if not want:
return None
for idx, tok in enumerate(tokens):
low = str(tok or "").strip().lower()
if "=" in low:
2026-01-23 16:46:48 -08:00
head, _ = low.split("=", 1)
if head in want:
return tok.split("=", 1)[1]
if low in want and idx + 1 < len(tokens):
return tokens[idx + 1]
return None
@staticmethod
2026-05-04 18:41:01 -07:00
def _effective_cmd_name(cmd_name: str, stage_tokens: Sequence[str]) -> str:
canonical_cmd = str(cmd_name or "").replace("_", "-").strip().lower()
2026-05-04 18:41:01 -07:00
if canonical_cmd != "file":
return canonical_cmd
lowered = {str(tok or "").strip().lower() for tok in (stage_tokens or [])}
2026-05-24 12:32:57 -07:00
if "-search" in lowered or "--search" in lowered:
return "search-file"
2026-05-04 18:41:01 -07:00
if "-download" in lowered or "--download" in lowered or "-dl" in lowered or "--dl" in lowered:
return "download-file"
if "-add" in lowered or "--add" in lowered:
return "add-file"
if "-delete" in lowered or "--delete" in lowered or "-del" in lowered or "--del" in lowered:
return "delete-file"
if "-merge" in lowered or "--merge" in lowered:
return "merge-file"
if "-query" in lowered or "--query" in lowered or any(tok.startswith("-query=") or tok.startswith("--query=") for tok in lowered):
return "search-file"
2026-05-04 18:41:01 -07:00
return canonical_cmd
@staticmethod
def _selected_plugin_name(cmd_name: str, stage_tokens: Sequence[str]) -> Optional[str]:
canonical_cmd = CmdletCompleter._effective_cmd_name(cmd_name, stage_tokens)
2026-05-24 12:32:57 -07:00
if canonical_cmd not in {"file", "search-file", "add-file", "download-file"}:
return None
raw_plugin = CmdletCompleter._flag_value(stage_tokens, "-plugin", "--plugin")
if raw_plugin:
# Strip quotes if present, then normalize to lowercase
stripped = CmdletCompleter._strip_quotes(str(raw_plugin or ""))
return stripped.strip().lower() if stripped else None
return None
@staticmethod
def _plugin_instance_choices(plugin_name: Optional[str], config: Dict[str, Any]) -> List[str]:
plugin_key = str(plugin_name or "").strip().lower()
if not plugin_key:
return []
try:
2026-05-21 16:19:17 -07:00
from PluginCore.registry import get_plugin_class
except Exception:
return []
plugin_class = get_plugin_class(plugin_key)
if plugin_class is None:
return []
try:
plugin = plugin_class(config)
except Exception:
return []
try:
instances = plugin.configured_instances()
except Exception:
return []
out: List[str] = []
seen: Set[str] = set()
for value in instances or []:
text = str(value or "").strip()
lowered = text.lower()
if not text or lowered in seen:
continue
seen.add(lowered)
out.append(text)
return out
@staticmethod
def _plugin_instance_accepts_direct_path(plugin_name: Optional[str]) -> bool:
return str(plugin_name or "").strip().lower() == "local"
@staticmethod
def _looks_like_path_fragment(value: str) -> bool:
text = str(value or "").strip()
if not text:
return False
if text[:1] in {"'", '"'}:
text = text[1:]
if not text:
return False
if text.startswith((".", "~", "\\", "/")):
return True
if "\\" in text or "/" in text:
return True
if len(text) >= 2 and text[1] == ":":
return True
return False
@staticmethod
def _path_instance_choices(current_token: str) -> List[str]:
raw = str(current_token or "")
if not CmdletCompleter._looks_like_path_fragment(raw):
return []
quote_prefix = raw[:1] if raw[:1] in {"'", '"'} else ""
fragment = raw[1:] if quote_prefix else raw
if not fragment:
return []
expanded = os.path.expanduser(fragment)
candidate = Path(expanded)
if fragment.endswith(("\\", "/")):
parent = candidate
prefix = ""
else:
parent = candidate.parent if str(candidate.parent) not in {"", "."} else Path.cwd()
prefix = candidate.name
try:
if not parent.exists() or not parent.is_dir():
return []
except Exception:
return []
out: List[str] = []
seen: Set[str] = set()
prefix_lower = prefix.lower()
try:
entries = sorted(parent.iterdir(), key=lambda item: item.name.lower())
except Exception:
return []
for entry in entries:
try:
if not entry.is_dir():
continue
if prefix_lower and not entry.name.lower().startswith(prefix_lower):
continue
suggestion = str(entry)
if quote_prefix:
suggestion = quote_prefix + suggestion
elif " " in suggestion:
suggestion = f'"{suggestion}"'
except Exception:
continue
lowered = suggestion.lower()
if lowered in seen:
continue
seen.add(lowered)
out.append(suggestion)
return out
2026-05-24 12:32:57 -07:00
def _file_stage_order(
self,
*,
stage_tokens: Sequence[str],
config: Dict[str, Any],
) -> Optional[List[str]]:
if self._effective_cmd_name("file", stage_tokens) != "file":
return None
plugin_name = self._selected_plugin_name("file", stage_tokens)
if not plugin_name:
return [
"search",
"add",
"delete",
"merge",
"download",
"convert",
"trim",
"archive",
"screenshot",
]
plugin_key = str(plugin_name or "").strip().lower()
supports_search = plugin_key in {
str(name or "").strip().lower()
for name in self._plugins_for_cmdlet("search-file", config)
}
supports_any_action = False
ordered: List[str] = []
instance_choices = self._plugin_instance_choices(plugin_name, config)
accepts_direct_path = self._plugin_instance_accepts_direct_path(plugin_name)
if instance_choices or accepts_direct_path:
ordered.append("instance")
if supports_search:
ordered.append("query")
for logical, target_cmd in self._FILE_STAGE_ACTION_CMDLETS:
supported = plugin_key in {
str(name or "").strip().lower()
for name in self._plugins_for_cmdlet(target_cmd, config)
}
if not supported:
continue
supports_any_action = True
if logical not in ordered:
ordered.append(logical)
if supports_search or supports_any_action:
return ordered
return []
def _file_search_stage_order(
self,
*,
stage_tokens: Sequence[str],
config: Dict[str, Any],
) -> List[str]:
plugin_name = self._selected_plugin_name("search-file", stage_tokens)
if not plugin_name:
return ["plugin", "query", "limit"]
ordered = ["query"]
if self._plugin_instance_choices(plugin_name, config):
ordered.append("instance")
ordered.extend(["plugin", "limit"])
return ordered
def _filter_stage_arg_names(
self,
*,
cmd_name: str,
stage_tokens: Sequence[str],
config: Dict[str, Any],
arg_names: List[str],
) -> List[str]:
if not arg_names:
return []
2026-05-24 12:32:57 -07:00
source_cmd = (
str(stage_tokens[0] or "").replace("_", "-").strip().lower()
if stage_tokens else str(cmd_name or "").replace("_", "-").strip().lower()
)
canonical_cmd = self._effective_cmd_name(source_cmd, stage_tokens)
plugin_name = self._selected_plugin_name(canonical_cmd, stage_tokens)
instance_choices = self._plugin_instance_choices(plugin_name, config)
has_named_instances = bool(instance_choices)
accepts_direct_path = self._plugin_instance_accepts_direct_path(plugin_name)
2026-05-24 12:32:57 -07:00
allow_instance_without_plugin = canonical_cmd == "search-file" and source_cmd != "file"
2026-05-24 12:32:57 -07:00
file_stage_order = None
file_stage_rank: Dict[str, int] = {}
if source_cmd == "file" and canonical_cmd == "file":
file_stage_order = self._file_stage_order(
stage_tokens=stage_tokens,
config=config,
)
if file_stage_order is not None:
file_stage_rank = {
logical: idx for idx, logical in enumerate(file_stage_order)
}
elif source_cmd == "file" and canonical_cmd == "search-file":
file_stage_order = self._file_search_stage_order(
stage_tokens=stage_tokens,
config=config,
)
file_stage_rank = {
logical: idx for idx, logical in enumerate(file_stage_order)
}
filtered: List[Tuple[int, int, str]] = []
for index, arg in enumerate(arg_names):
logical = str(arg or "").lstrip("-").strip().lower()
2026-05-24 12:32:57 -07:00
if file_stage_order is not None and logical not in file_stage_rank:
continue
if logical == "open":
continue
if logical == "instance":
2026-05-24 12:32:57 -07:00
if allow_instance_without_plugin:
pass
elif not plugin_name:
continue
2026-05-24 12:32:57 -07:00
if not allow_instance_without_plugin and not has_named_instances and not accepts_direct_path:
continue
2026-05-24 12:32:57 -07:00
rank = file_stage_rank.get(logical, len(file_stage_rank))
filtered.append((rank, index, arg))
if file_stage_order is not None:
filtered.sort(key=lambda item: (item[0], item[1]))
return [arg for _, _, arg in filtered]
@staticmethod
def _tokenize_quoted(text: str) -> List[str]:
"""Tokenize text preserving quoted strings as single tokens.
Handles pipes as pipeline separators and preserves quoted strings
(single or double quotes) as atomic tokens.
"""
tokens = []
current = ""
in_quote = None # None, "'", or '"'
i = 0
while i < len(text):
char = text[i]
if in_quote:
current += char
if char == in_quote and (i == 0 or text[i - 1] != "\\"):
in_quote = None
elif char in ("'", '"'):
in_quote = char
current += char
elif char == "|":
if current.strip():
tokens.append(current.strip())
tokens.append("|")
current = ""
elif char.isspace():
if current.strip():
tokens.append(current.strip())
current = ""
else:
current += char
i += 1
if current.strip():
tokens.append(current.strip())
return tokens
@staticmethod
def _strip_quotes(token: str) -> str:
"""Remove surrounding quotes from a token if present.
Preserves internal content exactly. Only removes matching outer quotes.
"""
token = str(token or "").strip()
if len(token) >= 2:
if (token[0] == '"' and token[-1] == '"') or (token[0] == "'" and token[-1] == "'"):
return token[1:-1]
return token
def get_completions(
self,
document: Document,
complete_event
): # type: ignore[override]
2026-04-26 15:08:35 -07:00
self._refresh_cmdlet_names()
2026-01-11 00:39:17 -08:00
2025-12-20 02:12:45 -08:00
text = document.text_before_cursor
tokens = self._tokenize_quoted(text)
2025-12-20 02:12:45 -08:00
ends_with_space = bool(text) and text[-1].isspace()
last_pipe = -1
for idx, tok in enumerate(tokens):
if tok == "|":
last_pipe = idx
stage_tokens = tokens[last_pipe + 1:] if last_pipe >= 0 else tokens
2025-12-20 02:12:45 -08:00
if not stage_tokens:
for cmd in self.cmdlet_names:
yield Completion(cmd, start_position=0)
return
if len(stage_tokens) == 1:
current = stage_tokens[0].lower()
if ends_with_space:
cmd_name = current.replace("_", "-")
2026-04-26 15:08:35 -07:00
config = self._config_loader.load_shared()
2025-12-24 17:58:57 -08:00
2025-12-20 02:12:45 -08:00
if cmd_name == "help":
for cmd in self.cmdlet_names:
yield Completion(cmd, start_position=0)
return
if cmd_name not in self.cmdlet_names:
return
arg_names = self._filter_stage_arg_names(
cmd_name=cmd_name,
stage_tokens=stage_tokens,
config=config,
arg_names=self._cmdlet_args(cmd_name, config),
)
2026-01-19 06:24:09 -08:00
seen_logicals: Set[str] = set()
2025-12-20 02:12:45 -08:00
for arg in arg_names:
arg_low = arg.lower()
if arg_low.startswith("--"):
continue
logical = arg.lstrip("-").lower()
2026-01-19 06:24:09 -08:00
if logical in seen_logicals:
2025-12-20 02:12:45 -08:00
continue
yield Completion(arg, start_position=0)
2026-01-19 06:24:09 -08:00
seen_logicals.add(logical)
2025-12-20 02:12:45 -08:00
return
for cmd in self.cmdlet_names:
if cmd.startswith(current):
yield Completion(cmd, start_position=-len(current))
for keyword in ("help", "exit", "quit"):
if keyword.startswith(current):
yield Completion(keyword, start_position=-len(current))
return
cmd_name = stage_tokens[0].replace("_", "-").lower()
2026-05-04 18:41:01 -07:00
effective_cmd = self._effective_cmd_name(cmd_name, stage_tokens)
2025-12-20 02:12:45 -08:00
if ends_with_space:
raw_current_token = ""
2025-12-20 02:12:45 -08:00
current_token = ""
prev_token = stage_tokens[-1].lower()
else:
raw_current_token = stage_tokens[-1]
current_token = raw_current_token.lower()
2025-12-20 02:12:45 -08:00
prev_token = stage_tokens[-2].lower() if len(stage_tokens) > 1 else ""
2026-04-26 15:08:35 -07:00
config = self._config_loader.load_shared()
provider_name = None
2026-05-04 18:41:01 -07:00
if effective_cmd == "search-file":
raw_provider = self._flag_value(stage_tokens, "-plugin", "--plugin")
if raw_provider:
stripped = self._strip_quotes(str(raw_provider or ""))
provider_name = stripped.strip().lower() if stripped else None
2026-05-04 18:41:01 -07:00
selected_plugin = self._selected_plugin_name(effective_cmd, stage_tokens)
2026-05-04 18:41:01 -07:00
query_specs = self._query_args(effective_cmd, config)
query_flag_index = -1
for idx, tok in enumerate(stage_tokens):
if str(tok or "").strip().lower() in {"-query", "--query"}:
query_flag_index = idx
if query_specs and query_flag_index >= 0:
query_parts = stage_tokens[query_flag_index + 1:]
query_started_quoted = bool(query_parts and str(query_parts[0] or "")[:1] in {"'", '"'})
query_fragment: Optional[str] = None
if prev_token in {"-query", "--query"} and current_token[:1] in {"'", '"'}:
query_fragment = current_token
elif query_started_quoted and not ends_with_space and not current_token.startswith("-"):
# Only continue in query mode if the previous token is not a flag (new argument)
if not prev_token.startswith("-"):
query_fragment = current_token
elif query_started_quoted and ends_with_space and ":" in prev_token:
query_fragment = ""
if query_fragment is not None:
field_choices: Dict[str, List[str]] = {}
ordered_fields: List[str] = []
for spec in query_specs:
key = str(spec.get("query_key") or spec.get("name") or "").strip().lower()
if not key:
continue
if key not in field_choices:
ordered_fields.append(key)
field_choices[key] = [str(choice) for choice in list(spec.get("choices", []) or [])]
for alias in spec.get("query_aliases", []) or []:
alias_text = str(alias or "").strip().lower()
if not alias_text:
continue
field_choices.setdefault(alias_text, field_choices[key])
raw_fragment = str(query_fragment or "")
segment = raw_fragment[1:] if raw_fragment[:1] in {"'", '"'} else raw_fragment
if "," in segment:
segment = segment.rsplit(",", 1)[-1].lstrip()
segment = segment.lstrip()
if ":" in segment:
field, partial = segment.split(":", 1)
field = field.strip().lower()
partial_lower = partial.strip().lower()
inline_choices = []
2026-05-04 18:41:01 -07:00
if effective_cmd == "search-file" and provider_name:
2026-04-26 15:08:35 -07:00
inline_choices = self._inline_query_choices(provider_name, field, config)
choice_pool = inline_choices or field_choices.get(field, [])
if choice_pool:
filtered = (
[choice for choice in choice_pool if partial_lower in str(choice).lower()]
if partial_lower else list(choice_pool)
)
for choice in (filtered or choice_pool):
yield Completion(str(choice), start_position=-len(partial))
return
else:
partial_lower = segment.strip().lower()
field_pool = ordered_fields
filtered_fields = (
[field for field in field_pool if field.startswith(partial_lower)]
if partial_lower else field_pool
)
for field in (filtered_fields or field_pool):
yield Completion(f"{field}:", start_position=-len(segment))
if filtered_fields or field_pool:
return
if (
2026-05-04 18:41:01 -07:00
effective_cmd == "search-file"
and provider_name
and not ends_with_space
and ":" in current_token
and not current_token.startswith("-")
):
# Allow quoted tokens like "system:g
quote_prefix = current_token[0] if current_token[:1] in {"'", '"'} else ""
inline_token = current_token[1:] if quote_prefix else current_token
if inline_token.endswith(quote_prefix) and len(inline_token) > 1:
inline_token = inline_token[:-1]
# Allow comma-separated inline specs; operate on the last segment only.
if "," in inline_token:
inline_token = inline_token.split(",")[-1].lstrip()
if ":" not in inline_token:
return
field, partial = inline_token.split(":", 1)
field = field.strip().lower()
partial_lower = partial.strip().lower()
2026-04-26 15:08:35 -07:00
inline_choices = self._inline_query_choices(provider_name, field, config)
if inline_choices:
filtered = (
[c for c in inline_choices if partial_lower in str(c).lower()]
if partial_lower
else list(inline_choices)
)
for choice in (filtered or inline_choices):
# Replace only the partial after the colon; keep the field prefix and quotes as typed.
start_pos = -len(partial)
suggestion = str(choice)
yield Completion(suggestion, start_position=start_pos)
return
normalized_prev = prev_token.lstrip("-").strip().lower()
choices: List[str] = []
if normalized_prev == "instance" and selected_plugin:
choices = self._plugin_instance_choices(selected_plugin, config)
if self._plugin_instance_accepts_direct_path(selected_plugin):
path_choices = self._path_instance_choices(raw_current_token)
if path_choices:
seen_choice_values = {str(choice).lower() for choice in choices}
for choice in path_choices:
lowered = str(choice).lower()
if lowered in seen_choice_values:
continue
choices.append(choice)
seen_choice_values.add(lowered)
if not choices:
choices = self._arg_choices(
2026-05-04 18:41:01 -07:00
cmd_name=effective_cmd,
arg_name=prev_token,
config=config,
force=False,
)
2025-12-20 02:12:45 -08:00
if choices:
2026-01-05 07:51:19 -08:00
choice_list = choices
2026-05-26 15:32:01 -07:00
if normalized_prev == "plugin" and current_token:
2026-01-05 07:51:19 -08:00
current_lower = current_token.lower()
2026-05-24 12:32:57 -07:00
filtered = [c for c in choices if c.lower().startswith(current_lower)]
2026-01-05 07:51:19 -08:00
if filtered:
choice_list = filtered
if normalized_prev == "instance" and current_token:
current_lower = current_token.lower()
filtered = [c for c in choice_list if current_lower in c.lower()]
if filtered:
choice_list = filtered
2026-01-05 07:51:19 -08:00
for choice in choice_list:
yield Completion(choice, start_position=-len(raw_current_token))
2026-01-01 20:37:27 -08:00
# Example: if the user has typed `download-file -url ...`, then `url`
# is considered used and should not be suggested again (even as `--url`).
2025-12-20 02:12:45 -08:00
return
arg_names = self._filter_stage_arg_names(
2026-05-04 18:41:01 -07:00
cmd_name=effective_cmd,
stage_tokens=stage_tokens,
config=config,
2026-05-04 18:41:01 -07:00
arg_names=self._cmdlet_args(effective_cmd, config),
)
2026-05-04 18:41:01 -07:00
used_logicals = self._used_arg_logicals(effective_cmd, stage_tokens, config)
2025-12-20 02:12:45 -08:00
logical_seen: Set[str] = set()
for arg in arg_names:
arg_low = arg.lower()
prefer_single_dash = current_token in {"",
"-"}
2025-12-20 02:12:45 -08:00
if prefer_single_dash and arg_low.startswith("--"):
2025-12-16 01:45:01 -08:00
continue
2025-12-20 02:12:45 -08:00
logical = arg.lstrip("-").lower()
2025-12-20 23:57:44 -08:00
if logical in used_logicals:
continue
2025-12-20 02:12:45 -08:00
if prefer_single_dash and logical in logical_seen:
continue
if arg_low.startswith(current_token):
yield Completion(arg, start_position=-len(current_token))
if prefer_single_dash:
logical_seen.add(logical)
2025-12-16 01:45:01 -08:00
2025-12-20 02:12:45 -08:00
class ConfigLoader:
2025-12-20 02:12:45 -08:00
def __init__(self, *, root: Path) -> None:
self._root = root
2026-04-26 15:08:35 -07:00
def load_shared(self) -> Dict[str, Any]:
try:
return load_config(emit_summary=False)
except Exception:
return {}
2025-12-20 02:12:45 -08:00
def load(self) -> Dict[str, Any]:
try:
2026-04-26 15:08:35 -07:00
return deepcopy(self.load_shared())
2025-12-20 02:12:45 -08:00
except Exception:
return {}
class CmdletHelp:
2025-12-20 02:12:45 -08:00
@staticmethod
def show_cmdlet_list() -> None:
try:
metadata = list_cmdlet_metadata() or {}
2025-12-20 23:57:44 -08:00
from rich.box import SIMPLE
from rich.panel import Panel
from rich.table import Table as RichTable
table = RichTable(
show_header=True,
header_style="bold",
box=SIMPLE,
expand=True
)
2025-12-20 23:57:44 -08:00
table.add_column("Cmdlet", no_wrap=True)
table.add_column("Aliases")
table.add_column("Args")
table.add_column("Summary")
2025-12-20 02:12:45 -08:00
for cmd_name in sorted(metadata.keys()):
info = metadata[cmd_name]
aliases = info.get("aliases", [])
args = info.get("args", [])
2025-12-20 23:57:44 -08:00
summary = info.get("summary") or ""
alias_str = ", ".join(
[str(a) for a in (aliases or []) if str(a).strip()]
)
2025-12-29 17:05:03 -08:00
arg_names = [
a.get("name") for a in (args or [])
if isinstance(a, dict) and a.get("name")
2025-12-29 17:05:03 -08:00
]
2025-12-20 23:57:44 -08:00
args_str = ", ".join([str(a) for a in arg_names if str(a).strip()])
table.add_row(str(cmd_name), alias_str, args_str, str(summary))
2025-12-20 02:12:45 -08:00
2025-12-20 23:57:44 -08:00
stdout_console().print(Panel(table, title="Cmdlets", expand=False))
2025-12-20 02:12:45 -08:00
except Exception as exc:
2025-12-20 23:57:44 -08:00
from rich.panel import Panel
from rich.text import Text
stderr_console().print(
Panel(Text(f"Error: {exc}"),
title="Error",
expand=False)
)
2025-12-20 02:12:45 -08:00
@staticmethod
def show_cmdlet_help(cmd_name: str) -> None:
try:
meta = get_cmdlet_metadata(cmd_name)
if meta:
CmdletHelp._print_metadata(cmd_name, meta)
return
print(f"Unknown command: {cmd_name}\n")
except Exception as exc:
print(f"Error: {exc}\n")
@staticmethod
def _print_metadata(cmd_name: str, data: Any) -> None:
d = data.to_dict() if hasattr(data, "to_dict") else data
if not isinstance(d, dict):
2025-12-20 23:57:44 -08:00
from rich.panel import Panel
from rich.text import Text
2025-12-29 17:05:03 -08:00
stderr_console().print(
Panel(
Text(f"Invalid metadata for {cmd_name}"),
title="Error",
expand=False
)
2025-12-29 17:05:03 -08:00
)
2025-12-20 02:12:45 -08:00
return
name = d.get("name", cmd_name)
summary = d.get("summary", "")
usage = d.get("usage", "")
description = d.get("description", "")
args = d.get("args", [])
details = d.get("details", [])
2025-12-20 23:57:44 -08:00
from rich.box import SIMPLE
from rich.console import Group
from rich.panel import Panel
from rich.table import Table as RichTable
from rich.text import Text
2025-12-20 02:12:45 -08:00
2025-12-20 23:57:44 -08:00
header = Text.assemble((str(name), "bold"))
synopsis = Text(str(usage or name))
stdout_console().print(
Panel(Group(header,
synopsis),
title="Help",
expand=False)
)
2025-12-20 02:12:45 -08:00
if summary or description:
2025-12-20 23:57:44 -08:00
desc_bits: List[Text] = []
2025-12-20 02:12:45 -08:00
if summary:
2025-12-20 23:57:44 -08:00
desc_bits.append(Text(str(summary)))
2025-12-20 02:12:45 -08:00
if description:
2025-12-20 23:57:44 -08:00
desc_bits.append(Text(str(description)))
stdout_console().print(
Panel(Group(*desc_bits),
title="Description",
expand=False)
)
2025-12-20 02:12:45 -08:00
if args and isinstance(args, list):
param_table = RichTable(
show_header=True,
header_style="bold",
box=SIMPLE,
expand=True
)
2025-12-20 23:57:44 -08:00
param_table.add_column("Arg", no_wrap=True)
param_table.add_column("Type", no_wrap=True)
param_table.add_column("Required", no_wrap=True)
param_table.add_column("Description")
2025-12-20 02:12:45 -08:00
for arg in args:
if isinstance(arg, dict):
name_str = arg.get("name", "?")
typ = arg.get("type", "string")
2025-12-20 23:57:44 -08:00
required = bool(arg.get("required", False))
2025-12-20 02:12:45 -08:00
desc = arg.get("description", "")
else:
name_str = getattr(arg, "name", "?")
typ = getattr(arg, "type", "string")
2025-12-20 23:57:44 -08:00
required = bool(getattr(arg, "required", False))
2025-12-20 02:12:45 -08:00
desc = getattr(arg, "description", "")
2025-12-29 17:05:03 -08:00
param_table.add_row(
f"-{name_str}",
str(typ),
"yes" if required else "no",
str(desc or "")
2025-12-29 17:05:03 -08:00
)
2025-12-20 23:57:44 -08:00
stdout_console().print(Panel(param_table, title="Parameters", expand=False))
2025-12-20 02:12:45 -08:00
if details:
2025-12-29 17:05:03 -08:00
stdout_console().print(
Panel(
Group(*[Text(str(x)) for x in details]),
title="Remarks",
expand=False
)
2025-12-29 17:05:03 -08:00
)
2025-12-20 02:12:45 -08:00
class CmdletExecutor:
2025-12-20 02:12:45 -08:00
def __init__(self, *, config_loader: ConfigLoader) -> None:
self._config_loader = config_loader
@staticmethod
def _get_table_title_for_command(
cmd_name: str,
emitted_items: Optional[List[Any]] = None,
cmd_args: Optional[List[str]] = None,
) -> str:
2026-05-04 18:41:01 -07:00
def _file_action(args: Optional[List[str]]) -> str | None:
tokens = [str(t or "").strip().lower() for t in (args or [])]
token_set = set(tokens)
2026-05-24 12:32:57 -07:00
if "-search" in token_set or "--search" in token_set:
return "search-file"
2026-05-04 18:41:01 -07:00
if "-download" in token_set or "--download" in token_set or "-dl" in token_set or "--dl" in token_set:
return "download-file"
if "-add" in token_set or "--add" in token_set:
return "add-file"
if "-delete" in token_set or "--delete" in token_set or "-del" in token_set or "--del" in token_set:
return "delete-file"
if "-merge" in token_set or "--merge" in token_set:
return "merge-file"
if "-query" in token_set or "--query" in token_set or any(tok.startswith("-query=") or tok.startswith("--query=") for tok in token_set):
return "search-file"
2026-05-04 18:41:01 -07:00
return None
normalized_cmd = str(cmd_name or "").replace("_", "-").lower().strip()
mapped_cmd = _file_action(cmd_args) if normalized_cmd == "file" else normalized_cmd
2025-12-20 02:12:45 -08:00
title_map = {
"search-file": "Results",
"search_file": "Results",
"download-data": "Downloads",
"download_data": "Downloads",
2025-12-29 17:05:03 -08:00
"download-file": "Downloads",
"download_file": "Downloads",
2026-05-04 18:41:01 -07:00
"metadata": "Tags",
2025-12-20 02:12:45 -08:00
"add-url": "Results",
"add_url": "Results",
"get-url": "url",
"get_url": "url",
"delete-url": "Results",
"delete_url": "Results",
"get-note": "Notes",
"get_note": "Notes",
"add-note": "Results",
"add_note": "Results",
"delete-note": "Results",
"delete_note": "Results",
"get-relationship": "Relationships",
"get_relationship": "Relationships",
"add-relationship": "Results",
"add_relationship": "Results",
"add-file": "Results",
"add_file": "Results",
"delete-file": "Results",
"delete_file": "Results",
"get-metadata": None,
"get_metadata": None,
}
2026-05-04 18:41:01 -07:00
mapped = title_map.get(mapped_cmd or normalized_cmd, "Results")
2025-12-20 02:12:45 -08:00
if mapped is not None:
return mapped
if emitted_items:
first = emitted_items[0]
try:
if isinstance(first, dict) and first.get("title"):
return str(first.get("title"))
if hasattr(first, "title") and getattr(first, "title"):
return str(getattr(first, "title"))
except Exception:
pass
return "Results"
def execute(self, cmd_name: str, args: List[str]) -> None:
from SYS import pipeline as ctx
2025-12-20 02:12:45 -08:00
from cmdlet import REGISTRY
2025-12-17 17:42:46 -08:00
2025-12-27 14:50:59 -08:00
# REPL guard: stage-local selection tables should not leak across independent
# commands. @ selection can always re-seed from the last result table.
try:
if hasattr(ctx, "set_current_stage_table"):
ctx.set_current_stage_table(None)
except Exception:
pass
2025-12-20 02:12:45 -08:00
cmd_fn = REGISTRY.get(cmd_name)
2026-03-27 15:45:05 -07:00
try:
mod = import_cmd_module(cmd_name, reload_loaded=True)
data = getattr(mod, "CMDLET", None) if mod else None
if data and hasattr(data, "exec") and callable(getattr(data, "exec")):
2026-05-26 15:32:01 -07:00
from SYS.cmdlet_spec import collect_registered_cmdlet_names
2026-03-27 15:45:05 -07:00
run_fn = getattr(data, "exec")
2026-05-26 15:32:01 -07:00
for registered_name in collect_registered_cmdlet_names(data, fallback_name=cmd_name):
2026-03-27 15:45:05 -07:00
REGISTRY[registered_name] = run_fn
cmd_fn = run_fn
except Exception:
pass
2025-12-20 02:12:45 -08:00
if not cmd_fn:
# Lazy-import module and register its CMDLET.
try:
mod = import_cmd_module(cmd_name)
data = getattr(mod, "CMDLET", None) if mod else None
if data and hasattr(data, "exec") and callable(getattr(data, "exec")):
run_fn = getattr(data, "exec")
REGISTRY[cmd_name] = run_fn
cmd_fn = run_fn
except Exception:
cmd_fn = None
if not cmd_fn:
print(f"Unknown command: {cmd_name}\n")
2026-03-19 13:08:15 -07:00
try:
ctx.set_last_execution_result(
status="failed",
error=f"Unknown command: {cmd_name}",
command_text=" ".join([cmd_name, *args]).strip() or cmd_name,
)
except Exception:
pass
2025-12-20 02:12:45 -08:00
return
config = self._config_loader.load()
2025-12-23 16:36:39 -08:00
# ------------------------------------------------------------------
# Single-command Live pipeline progress (match REPL behavior)
# ------------------------------------------------------------------
progress_ui = None
pipe_idx: Optional[int] = None
def _maybe_start_single_live_progress(
*,
cmd_name_norm: str,
filtered_args: List[str],
piped_input: Any,
config: Any,
) -> None:
nonlocal progress_ui, pipe_idx
2026-05-04 18:41:01 -07:00
def _effective_file_cmd(name: str, args: List[str]) -> str:
norm = str(name or "").replace("_", "-").strip().lower()
if norm != "file":
return norm
lowered = {str(a or "").strip().lower() for a in (args or [])}
if "-add" in lowered or "--add" in lowered:
return "add-file"
if "-delete" in lowered or "--delete" in lowered or "-del" in lowered or "--del" in lowered:
return "delete-file"
if "-download" in lowered or "--download" in lowered or "-dl" in lowered or "--dl" in lowered:
return "download-file"
return norm
effective_cmd = _effective_file_cmd(cmd_name_norm, filtered_args)
2025-12-23 16:36:39 -08:00
# Keep behavior consistent with pipeline runner exclusions.
2025-12-26 18:58:48 -08:00
# Some commands render their own Rich UI (tables/panels) and don't
# play nicely with Live cursor control.
2026-05-04 18:41:01 -07:00
if effective_cmd in {
"get-relationship",
"get-rel",
".pipe",
2026-01-03 03:37:48 -08:00
".mpv",
".matrix",
".telegram",
"telegram",
"delete-file",
"del-file",
2025-12-29 17:05:03 -08:00
}:
2025-12-23 16:36:39 -08:00
return
2025-12-27 06:05:07 -08:00
# add-file directory selector mode: show only the selection table, no Live progress.
2026-05-04 18:41:01 -07:00
if effective_cmd in {"add-file", "add_file"}:
2025-12-27 06:05:07 -08:00
try:
from pathlib import Path as _Path
toks = list(filtered_args or [])
i = 0
while i < len(toks):
t = str(toks[i])
low = t.lower().strip()
if low in {"-path",
"--path",
"-p"} and i + 1 < len(toks):
2025-12-27 06:05:07 -08:00
nxt = str(toks[i + 1])
if nxt and ("," not in nxt):
p = _Path(nxt)
if p.exists() and p.is_dir():
return
i += 2
continue
i += 1
except Exception:
pass
2025-12-23 16:36:39 -08:00
try:
2025-12-29 17:05:03 -08:00
quiet_mode = (
bool(config.get("_quiet_background_output"))
if isinstance(config,
dict) else False
2025-12-29 17:05:03 -08:00
)
2025-12-23 16:36:39 -08:00
except Exception:
quiet_mode = False
if quiet_mode:
return
try:
import sys as _sys
if not bool(getattr(_sys.stderr, "isatty", lambda: False)()):
return
except Exception:
return
try:
from SYS.models import PipelineLiveProgress
2025-12-23 16:36:39 -08:00
progress_ui = PipelineLiveProgress([cmd_name_norm], enabled=True)
progress_ui.start()
try:
if hasattr(ctx, "set_live_progress"):
ctx.set_live_progress(progress_ui)
except Exception:
pass
2026-03-21 15:12:52 -07:00
try:
progress_cb = (
ctx.get_progress_event_callback()
if hasattr(ctx, "get_progress_event_callback") else None
)
if callable(progress_cb) and hasattr(progress_ui, "set_event_callback"):
progress_ui.set_event_callback(progress_cb)
except Exception:
pass
2025-12-23 16:36:39 -08:00
pipe_idx = 0
# Estimate per-item task count for the single pipe.
total_items = 1
preview_items: Optional[List[Any]] = None
try:
if isinstance(piped_input, list):
total_items = max(1, int(len(piped_input)))
preview_items = list(piped_input)
elif piped_input is not None:
total_items = 1
preview_items = [piped_input]
else:
preview: List[Any] = []
toks = list(filtered_args or [])
i = 0
while i < len(toks):
t = str(toks[i])
low = t.lower().strip()
2026-05-04 18:41:01 -07:00
if (effective_cmd in {"add-file", "add_file"} and low in {"-path",
"--path",
"-p"}
and i + 1 < len(toks)):
2025-12-27 06:05:07 -08:00
nxt = str(toks[i + 1])
if nxt:
if "," in nxt:
parts = [
p.strip().strip("\"'")
for p in nxt.split(",")
]
2025-12-27 06:05:07 -08:00
parts = [p for p in parts if p]
if parts:
preview.extend(parts)
i += 2
continue
else:
preview.append(nxt)
i += 2
continue
if low in {"-url",
"--url"} and i + 1 < len(toks):
2025-12-23 16:36:39 -08:00
nxt = str(toks[i + 1])
if nxt and not nxt.startswith("-"):
preview.append(nxt)
i += 2
continue
if (not t.startswith("-")) and ("://" in low
or low.startswith(
("magnet:",
"torrent:"))):
2025-12-23 16:36:39 -08:00
preview.append(t)
i += 1
preview_items = preview if preview else None
total_items = max(1, int(len(preview)) if preview else 1)
except Exception:
total_items = 1
preview_items = None
try:
2025-12-29 17:05:03 -08:00
progress_ui.begin_pipe(
0,
total_items=int(total_items),
items_preview=preview_items
2025-12-29 17:05:03 -08:00
)
2025-12-23 16:36:39 -08:00
except Exception:
pass
except Exception:
progress_ui = None
pipe_idx = None
2025-12-20 02:12:45 -08:00
filtered_args: List[str] = []
selected_indices: List[int] = []
select_all = False
2025-12-30 04:47:13 -08:00
selection_filters: List[List[Tuple[str, str]]] = []
2025-12-20 02:12:45 -08:00
value_flags: Set[str] = set()
try:
meta = get_cmdlet_metadata(cmd_name)
raw = meta.get("raw") if isinstance(meta, dict) else None
arg_specs = getattr(raw, "arg", None) if raw is not None else None
if isinstance(arg_specs, list):
for spec in arg_specs:
spec_type = str(getattr(spec,
"type",
"string") or "string").strip().lower()
2025-12-20 02:12:45 -08:00
if spec_type == "flag":
continue
spec_name = str(getattr(spec, "name", "") or "")
canonical = spec_name.lstrip("-").strip()
if not canonical:
continue
value_flags.add(f"-{canonical}".lower())
value_flags.add(f"--{canonical}".lower())
alias = str(getattr(spec, "alias", "") or "").strip()
if alias:
value_flags.add(f"-{alias}".lower())
except Exception:
value_flags = set()
for i, arg in enumerate(args):
if isinstance(arg, str) and arg.startswith("@"): # selection candidate
prev = str(args[i - 1]).lower() if i > 0 else ""
if prev in value_flags:
filtered_args.append(arg)
continue
2025-12-30 04:47:13 -08:00
# Universal selection filter: @"COL:expr" (quotes may be stripped by tokenization)
filter_spec = SelectionFilterSyntax.parse(arg)
if filter_spec is not None:
selection_filters.append(filter_spec)
2025-12-20 02:12:45 -08:00
continue
if arg.strip() == "@*":
select_all = True
continue
selection = SelectionSyntax.parse(arg)
if selection is not None:
zero_based = sorted(idx - 1 for idx in selection)
for idx in zero_based:
if idx not in selected_indices:
selected_indices.append(idx)
continue
filtered_args.append(arg)
continue
filtered_args.append(str(arg))
2025-12-21 05:10:09 -08:00
# IMPORTANT: Do not implicitly feed the previous command's results into
# a new command unless the user explicitly selected items via @ syntax.
# Piping should require `|` (or an explicit @ selection).
2025-12-20 02:12:45 -08:00
piped_items = ctx.get_last_result_items()
result: Any = None
2025-12-30 04:47:13 -08:00
effective_selected_indices: List[int] = []
if piped_items and (select_all or selected_indices or selection_filters):
candidate_idxs = list(range(len(piped_items)))
for spec in selection_filters:
candidate_idxs = [
i for i in candidate_idxs
if SelectionFilterSyntax.matches(piped_items[i], spec)
2025-12-29 17:05:03 -08:00
]
2025-12-20 02:12:45 -08:00
2025-12-30 04:47:13 -08:00
if select_all:
effective_selected_indices = list(candidate_idxs)
elif selected_indices:
effective_selected_indices = [
candidate_idxs[i] for i in selected_indices
if 0 <= i < len(candidate_idxs)
]
else:
effective_selected_indices = list(candidate_idxs)
result = [piped_items[i] for i in effective_selected_indices]
2025-12-20 02:12:45 -08:00
worker_manager = WorkerManagerRegistry.ensure(config)
stage_session = WorkerStages.begin_stage(
worker_manager,
cmd_name=cmd_name,
stage_tokens=[cmd_name,
*filtered_args],
2025-12-20 02:12:45 -08:00
config=config,
command_text=" ".join([cmd_name,
*filtered_args]).strip() or cmd_name,
2025-12-20 02:12:45 -08:00
)
stage_worker_id = stage_session.worker_id if stage_session else None
2025-12-23 16:36:39 -08:00
# Start live progress after we know the effective cmd + args + piped input.
cmd_norm = str(cmd_name or "").replace("_", "-").strip().lower()
_maybe_start_single_live_progress(
cmd_name_norm=cmd_norm or str(cmd_name or "").strip().lower(),
filtered_args=filtered_args,
piped_input=result,
config=config,
)
on_emit = None
if progress_ui is not None and pipe_idx is not None:
_ui = progress_ui
def _on_emit(obj: Any, _progress=_ui) -> None:
try:
_progress.on_emit(0, obj)
except Exception:
pass
on_emit = _on_emit
pipeline_ctx = ctx.PipelineStageContext(
stage_index=0,
total_stages=1,
pipe_index=pipe_idx if pipe_idx is not None else 0,
worker_id=stage_worker_id,
on_emit=on_emit,
)
2025-12-20 02:12:45 -08:00
ctx.set_stage_context(pipeline_ctx)
stage_status = "completed"
stage_error = ""
2025-12-30 04:47:13 -08:00
ctx.set_last_selection(effective_selected_indices)
2025-12-20 02:12:45 -08:00
try:
2025-12-20 23:57:44 -08:00
try:
if hasattr(ctx, "set_current_cmdlet_name"):
ctx.set_current_cmdlet_name(cmd_name)
except Exception:
pass
try:
if hasattr(ctx, "set_current_stage_text"):
raw_stage = ""
try:
2025-12-29 17:05:03 -08:00
raw_stage = (
ctx.get_current_command_text("")
if hasattr(ctx,
"get_current_command_text") else ""
2025-12-29 17:05:03 -08:00
)
2025-12-20 23:57:44 -08:00
except Exception:
raw_stage = ""
if raw_stage:
ctx.set_current_stage_text(raw_stage)
else:
2025-12-29 17:05:03 -08:00
ctx.set_current_stage_text(
" ".join([cmd_name,
*filtered_args]).strip() or cmd_name
2025-12-29 17:05:03 -08:00
)
2025-12-20 23:57:44 -08:00
except Exception:
pass
2025-12-20 02:12:45 -08:00
ret_code = cmd_fn(result, filtered_args, config)
if getattr(pipeline_ctx, "emits", None):
emits = list(pipeline_ctx.emits)
2025-12-23 16:36:39 -08:00
# Shared `-path` behavior: if the cmdlet emitted temp/PATH file artifacts,
# move them to the user-specified destination and update emitted paths.
try:
from cmdlet import _shared as sh
2025-12-29 17:05:03 -08:00
emits = sh.apply_output_path_from_pipeobjects(
cmd_name=cmd_name,
args=filtered_args,
emits=emits
2025-12-29 17:05:03 -08:00
)
2025-12-23 16:36:39 -08:00
try:
pipeline_ctx.emits = list(emits)
except Exception:
pass
except Exception:
pass
2025-12-20 02:12:45 -08:00
# Detect format-selection emits and skip printing (user selects with @N).
is_format_selection = False
if emits:
first_emit = emits[0]
if isinstance(first_emit, dict) and "format_id" in first_emit:
is_format_selection = True
if is_format_selection:
ctx.set_last_result_items_only(emits)
else:
table_title = self._get_table_title_for_command(
cmd_name,
emits,
filtered_args
)
2025-12-20 02:12:45 -08:00
2026-05-04 18:41:01 -07:00
def _effective_cmd_name(name: str, args: List[str]) -> str:
norm = str(name or "").replace("_", "-").strip().lower()
if norm != "file":
return norm
lowered = {str(a or "").strip().lower() for a in (args or [])}
2026-05-24 12:32:57 -07:00
if "-search" in lowered or "--search" in lowered:
return "search-file"
2026-05-04 18:41:01 -07:00
if "-download" in lowered or "--download" in lowered or "-dl" in lowered or "--dl" in lowered:
return "download-file"
if "-add" in lowered or "--add" in lowered:
return "add-file"
if "-delete" in lowered or "--delete" in lowered or "-del" in lowered or "--del" in lowered:
return "delete-file"
if "-query" in lowered or "--query" in lowered or any(a.startswith("-query=") or a.startswith("--query=") for a in lowered):
return "search-file"
2026-05-04 18:41:01 -07:00
return norm
effective_cmd = _effective_cmd_name(cmd_name, filtered_args)
2025-12-20 02:12:45 -08:00
selectable_commands = {
"search-file",
"download-data",
2025-12-27 21:24:27 -08:00
"download-file",
2025-12-20 02:12:45 -08:00
"search_file",
"download_data",
2025-12-27 21:24:27 -08:00
"download_file",
2025-12-20 02:12:45 -08:00
".config",
".worker",
}
display_only_commands = {
"get-url",
"get_url",
"get-note",
"get_note",
"get-relationship",
"get_relationship",
2026-01-20 17:19:15 -08:00
"get-metadata",
"get_metadata",
2025-12-20 02:12:45 -08:00
}
self_managing_commands = {
2026-05-04 18:41:01 -07:00
"tag",
2025-12-20 02:12:45 -08:00
"tags",
2026-01-20 17:19:15 -08:00
"get-metadata",
"get_metadata",
2026-01-23 19:21:06 -08:00
"get-url",
"get_url",
2025-12-20 02:12:45 -08:00
"search-file",
"search_file",
2026-01-26 02:29:56 -08:00
"add-file",
"add_file",
2026-05-04 18:41:01 -07:00
"file",
2025-12-20 02:12:45 -08:00
}
2026-05-04 18:41:01 -07:00
if effective_cmd in self_managing_commands or cmd_name in self_managing_commands:
2026-01-20 17:19:15 -08:00
table = (
ctx.get_display_table()
if hasattr(ctx, "get_display_table") else None
)
if table is None:
table = ctx.get_last_result_table()
2025-12-20 02:12:45 -08:00
if table is None:
2026-01-19 03:14:30 -08:00
table = Table(table_title)
2025-12-20 02:12:45 -08:00
for emitted in emits:
table.add_result(emitted)
else:
2026-01-19 03:14:30 -08:00
table = Table(table_title)
2025-12-20 02:12:45 -08:00
for emitted in emits:
table.add_result(emitted)
2026-05-04 18:41:01 -07:00
if effective_cmd in selectable_commands:
table.set_source_command(effective_cmd, filtered_args)
2025-12-20 02:12:45 -08:00
ctx.set_last_result_table(table, emits)
ctx.set_current_stage_table(None)
2026-05-04 18:41:01 -07:00
elif effective_cmd in display_only_commands or cmd_name in display_only_commands:
2025-12-20 02:12:45 -08:00
ctx.set_last_result_items_only(emits)
else:
ctx.set_last_result_items_only(emits)
2025-12-23 16:36:39 -08:00
# Stop Live progress before printing tables.
if progress_ui is not None:
try:
if pipe_idx is not None:
2025-12-29 17:05:03 -08:00
progress_ui.finish_pipe(
int(pipe_idx),
force_complete=(stage_status == "completed")
2025-12-29 17:05:03 -08:00
)
2025-12-23 16:36:39 -08:00
except Exception:
pass
2026-01-20 00:31:44 -08:00
try:
progress_ui.complete_all_pipes()
except Exception:
pass
2025-12-23 16:36:39 -08:00
try:
progress_ui.stop()
except Exception:
pass
try:
if hasattr(ctx, "set_live_progress"):
ctx.set_live_progress(None)
except Exception:
pass
progress_ui = None
pipe_idx = None
2026-01-20 17:19:15 -08:00
if not getattr(table, "_rendered_by_cmdlet", False):
stdout_console().print()
stdout_console().print(table)
2025-12-20 02:12:45 -08:00
2025-12-23 16:36:39 -08:00
# If the cmdlet produced a current-stage table without emits (e.g. format selection),
# render it here for parity with REPL pipeline runner.
if (not getattr(pipeline_ctx,
"emits",
None)) and hasattr(ctx,
"get_current_stage_table"):
2025-12-23 16:36:39 -08:00
try:
stage_table = ctx.get_current_stage_table()
except Exception:
stage_table = None
if stage_table is not None:
try:
already_rendered = bool(
getattr(stage_table,
"_rendered_by_cmdlet",
False)
)
2025-12-23 16:36:39 -08:00
except Exception:
already_rendered = False
if already_rendered:
2026-03-19 13:08:15 -07:00
try:
ctx.set_last_execution_result(
status=stage_status,
error=stage_error,
command_text=" ".join([cmd_name, *filtered_args]).strip() or cmd_name,
)
except Exception:
pass
2025-12-23 16:36:39 -08:00
return
if progress_ui is not None:
try:
if pipe_idx is not None:
2025-12-29 17:05:03 -08:00
progress_ui.finish_pipe(
int(pipe_idx),
force_complete=(stage_status == "completed")
2025-12-29 17:05:03 -08:00
)
2025-12-23 16:36:39 -08:00
except Exception:
pass
2026-01-20 00:31:44 -08:00
try:
progress_ui.complete_all_pipes()
except Exception:
pass
2025-12-23 16:36:39 -08:00
try:
progress_ui.stop()
except Exception:
pass
try:
if hasattr(ctx, "set_live_progress"):
ctx.set_live_progress(None)
except Exception:
pass
progress_ui = None
pipe_idx = None
stdout_console().print()
stdout_console().print(stage_table)
2025-12-20 02:12:45 -08:00
if ret_code != 0:
stage_status = "failed"
stage_error = f"exit code {ret_code}"
2026-01-11 04:54:27 -08:00
# No print here - we want to keep output clean and avoid redundant "exit code" notices.
2025-12-20 02:12:45 -08:00
except Exception as exc:
stage_status = "failed"
stage_error = f"{type(exc).__name__}: {exc}"
print(f"[error] {type(exc).__name__}: {exc}\n")
finally:
2025-12-23 16:36:39 -08:00
if progress_ui is not None:
try:
if pipe_idx is not None:
2025-12-29 17:05:03 -08:00
progress_ui.finish_pipe(
int(pipe_idx),
force_complete=(stage_status == "completed")
2025-12-29 17:05:03 -08:00
)
2025-12-23 16:36:39 -08:00
except Exception:
pass
2026-01-20 00:31:44 -08:00
try:
progress_ui.complete_all_pipes()
except Exception:
pass
2025-12-23 16:36:39 -08:00
try:
progress_ui.stop()
except Exception:
pass
try:
if hasattr(ctx, "set_live_progress"):
ctx.set_live_progress(None)
except Exception:
pass
2025-12-27 14:50:59 -08:00
# Do not keep stage tables around after a single command; it can cause
# later @ selections to bind to stale tables (e.g. old add-file scans).
2026-03-19 13:08:15 -07:00
try:
if hasattr(ctx, "set_last_execution_result"):
ctx.set_last_execution_result(
status=stage_status,
error=stage_error,
command_text=" ".join([cmd_name, *filtered_args]).strip() or cmd_name,
)
except Exception:
pass
2025-12-27 14:50:59 -08:00
try:
if hasattr(ctx, "set_current_stage_table"):
ctx.set_current_stage_table(None)
except Exception:
pass
2025-12-20 23:57:44 -08:00
try:
if hasattr(ctx, "clear_current_cmdlet_name"):
ctx.clear_current_cmdlet_name()
except Exception:
pass
try:
if hasattr(ctx, "clear_current_stage_text"):
ctx.clear_current_stage_text()
except Exception:
pass
2025-12-20 02:12:45 -08:00
ctx.clear_last_selection()
if stage_session:
stage_session.close(status=stage_status, error_msg=stage_error)
2025-12-20 23:57:44 -08:00
console = Console()
2025-12-23 16:36:39 -08:00
2025-12-29 17:05:03 -08:00
2026-01-19 21:25:44 -08:00
class CLI:
2025-12-20 02:12:45 -08:00
"""Main CLI application object."""
ROOT = Path(__file__).resolve().parent
def __init__(self) -> None:
self._config_loader = ConfigLoader(root=self.ROOT)
2025-12-30 23:19:02 -08:00
# Optional dependency auto-install for configured tools (best-effort).
try:
from SYS.optional_deps import maybe_auto_install_configured_tools
maybe_auto_install_configured_tools(self._config_loader.load())
except Exception:
pass
2026-01-09 01:22:06 -08:00
# Initialize the store choices cache at startup (filters disabled stores)
try:
2026-05-26 15:32:01 -07:00
from SYS.cmdlet_spec import SharedArgs
2026-01-09 01:22:06 -08:00
config = self._config_loader.load()
SharedArgs._refresh_store_choices_cache(config)
except Exception:
pass
2025-12-20 02:12:45 -08:00
self._cmdlet_executor = CmdletExecutor(config_loader=self._config_loader)
self._pipeline_executor = PipelineExecutor(config_loader=self._config_loader)
@staticmethod
def parse_selection_syntax(token: str) -> Optional[Set[int]]:
return SelectionSyntax.parse(token)
@classmethod
def get_store_choices(cls) -> List[str]:
loader = ConfigLoader(root=cls.ROOT)
return CmdletIntrospection.store_choices(loader.load())
def build_app(self) -> typer.Typer:
app = typer.Typer(help="Medeia-Macina CLI")
def _validate_pipeline_option(
ctx: typer.Context,
param: typer.CallbackParam,
value: str
):
2025-12-21 05:10:09 -08:00
try:
from SYS.cli_syntax import validate_pipeline_text
2025-12-21 05:10:09 -08:00
2026-05-24 12:32:57 -07:00
syntax_error = validate_pipeline_text(
value,
config=self._config_loader.load(),
)
2025-12-21 05:10:09 -08:00
if syntax_error:
raise typer.BadParameter(syntax_error.message)
except typer.BadParameter:
raise
except Exception:
pass
return value
2025-12-20 02:12:45 -08:00
@app.command("pipeline")
def pipeline(
2025-12-21 05:10:09 -08:00
command: str = typer.Option(
2025-12-29 17:05:03 -08:00
...,
"--pipeline",
"-p",
help="Pipeline command string to execute",
callback=_validate_pipeline_option,
),
seeds_json: Optional[str] = typer.Option(
None,
"--seeds-json",
"-s",
help="JSON string of seed items"
2025-12-21 05:10:09 -08:00
),
2025-12-20 02:12:45 -08:00
) -> None:
from SYS import pipeline as ctx
2025-12-20 02:12:45 -08:00
config = self._config_loader.load()
debug_enabled = bool(config.get("debug", False))
set_debug(debug_enabled)
if seeds_json:
try:
seeds = json.loads(seeds_json)
if not isinstance(seeds, list):
seeds = [seeds]
ctx.set_last_result_items_only(seeds)
except Exception as exc:
print(f"Error parsing seeds JSON: {exc}")
return
try:
from SYS.cli_syntax import validate_pipeline_text
2025-12-20 02:12:45 -08:00
2026-05-24 12:32:57 -07:00
syntax_error = validate_pipeline_text(command, config=config)
2025-12-20 02:12:45 -08:00
if syntax_error:
print(syntax_error.message, file=sys.stderr)
return
except Exception:
pass
try:
tokens = shlex.split(command)
except ValueError as exc:
print(f"Syntax error: {exc}", file=sys.stderr)
2025-11-27 10:59:01 -08:00
return
2025-12-20 02:12:45 -08:00
if not tokens:
2025-12-12 21:55:38 -08:00
return
2025-12-20 02:12:45 -08:00
self._pipeline_executor.execute_tokens(tokens)
2025-12-12 21:55:38 -08:00
2025-12-20 02:12:45 -08:00
@app.command("repl")
def repl() -> None:
self.run_repl()
2025-11-27 10:59:01 -08:00
2025-12-20 02:12:45 -08:00
@app.callback(invoke_without_command=True)
def main_callback(ctx: typer.Context) -> None:
if ctx.invoked_subcommand is None:
self.run_repl()
2025-11-27 10:59:01 -08:00
2025-12-30 23:19:02 -08:00
_ = (pipeline, repl, main_callback)
2025-12-20 02:12:45 -08:00
2025-12-23 16:36:39 -08:00
# Dynamically register all cmdlets as top-level Typer commands so users can
# invoke `mm <cmdlet> [args]` directly from the shell. We use Click/Typer
# context settings to allow arbitrary flags and options to pass through to
# the cmdlet system without Typer trying to parse them.
try:
names = list_cmdlet_names()
2025-12-30 23:19:02 -08:00
skip = {"pipeline", "repl"}
2025-12-23 16:36:39 -08:00
for nm in names:
if not nm or nm in skip:
continue
2025-12-29 17:05:03 -08:00
2025-12-23 16:36:39 -08:00
# create a scoped handler to capture the command name
def _make_handler(cmd_name: str):
2025-12-29 17:05:03 -08:00
@app.command(
cmd_name,
context_settings={
"ignore_unknown_options": True,
2025-12-30 23:19:02 -08:00
"allow_extra_args": True,
},
2025-12-29 17:05:03 -08:00
)
2025-12-23 16:36:39 -08:00
def _handler(ctx: typer.Context):
try:
args = list(ctx.args or [])
except Exception:
args = []
self._cmdlet_executor.execute(cmd_name, args)
2025-12-29 17:05:03 -08:00
2025-12-23 16:36:39 -08:00
return _handler
_make_handler(nm)
except Exception:
# Don't let failure to register dynamic commands break startup
pass
2025-12-20 02:12:45 -08:00
return app
def run(self) -> None:
2025-12-20 23:57:44 -08:00
# Ensure Rich tracebacks are active even when invoking subcommands.
try:
config = self._config_loader.load()
debug_enabled = bool(config.get("debug",
False)
) if isinstance(config,
dict) else False
2025-12-20 23:57:44 -08:00
except Exception:
debug_enabled = False
set_debug(debug_enabled)
_install_rich_traceback(show_locals=debug_enabled)
2025-12-20 02:12:45 -08:00
self.build_app()()
def run_repl(self) -> None:
2025-12-29 17:05:03 -08:00
# console = Console(width=100)
2025-12-20 02:12:45 -08:00
2025-12-24 17:58:57 -08:00
# Valid Rich rainbow colors
RAINBOW = [
"red",
"dark_orange",
"yellow",
"green",
"blue",
"purple",
"magenta",
]
2025-12-23 16:36:39 -08:00
2025-12-24 17:58:57 -08:00
def rainbow_pillar(colors, height=21, bar_width=36):
2026-01-18 10:50:42 -08:00
table = RichTable.grid(padding=0)
2025-12-24 17:58:57 -08:00
table.add_column(no_wrap=True)
2025-12-23 16:36:39 -08:00
2025-12-24 17:58:57 -08:00
for i in range(height):
color = colors[i % len(colors)]
table.add_row(Bar(size=1, begin=0, end=1, width=bar_width, color=color))
2025-12-23 16:36:39 -08:00
2025-12-24 17:58:57 -08:00
return table
# Build root layout
root = Layout(name="root")
root.split_row(
Layout(name="left",
ratio=2),
Layout(name="center",
ratio=8),
Layout(name="right",
ratio=2),
2025-12-24 17:58:57 -08:00
)
# Left pillar → forward rainbow
root["left"].update(
Panel(rainbow_pillar(RAINBOW,
height=21,
bar_width=36),
title="DELTA")
)
2025-12-24 17:58:57 -08:00
# Right pillar → reverse rainbow
root["right"].update(
Panel(
rainbow_pillar(list(reversed(RAINBOW)),
height=21,
bar_width=36),
title="LAMBDA"
)
2025-12-24 17:58:57 -08:00
)
2025-12-29 17:05:03 -08:00
# Center content
2025-12-24 17:58:57 -08:00
center_md = Markdown(
2025-12-29 17:05:03 -08:00
"""
2025-12-24 17:58:57 -08:00
# ****************** Medios Macina ******************
take what you want | keep what you like | share what you love
_____________________________________________________________
_____________________________________________________________
_____________________________________________________________
For suddenly you may be let loose from the net, and thrown out to sea.
Waving around clutching at gnats, unable to lift the heavy anchor. Lost
and without a map, forgotten things from the past by distracting wind storms.
_____________________________________________________________
_____________________________________________________________
_____________________________________________________________
Light shines a straight path to the golden shores.
Come to love it when others take what you share, as there is no greater joy
2025-12-23 16:36:39 -08:00
"""
2025-12-29 17:05:03 -08:00
)
2025-12-24 17:58:57 -08:00
root["center"].update(Panel(center_md, title="KAPPA", height=21))
console.print(root)
2025-12-21 16:59:37 -08:00
prompt_text = "<🜂🜄|🜁🜃>"
2025-12-11 12:47:30 -08:00
2026-01-18 10:50:42 -08:00
startup_table = Table(
2025-12-20 02:12:45 -08:00
"*********<IGNITIO>*********<NOUSEMPEH>*********<RUGRAPOG>*********<OMEGHAU>*********"
)
2026-01-18 10:50:42 -08:00
startup_table._interactive(True)._perseverance(True)
2025-12-20 23:57:44 -08:00
startup_table.set_value_case("upper")
2025-12-17 03:16:41 -08:00
def _add_startup_check(
status: str,
name: str,
*,
provider: str = "",
2026-05-14 20:47:20 -07:00
instance: str = "",
2025-12-17 03:16:41 -08:00
files: int | str | None = None,
detail: str = "",
) -> None:
2026-03-25 22:39:30 -07:00
_shared_add_startup_check(
startup_table,
status,
name,
provider=provider,
2026-05-14 20:47:20 -07:00
instance=instance,
2026-03-25 22:39:30 -07:00
files=files,
detail=detail,
)
2025-12-16 01:45:01 -08:00
2025-12-20 02:12:45 -08:00
config = self._config_loader.load()
debug_enabled = bool(config.get("debug", False))
set_debug(debug_enabled)
2025-12-20 23:57:44 -08:00
_install_rich_traceback(show_locals=debug_enabled)
_add_startup_check("ENABLED" if debug_enabled else "DISABLED", "DEBUGGING")
2025-12-11 12:47:30 -08:00
try:
try:
2026-04-30 18:56:22 -07:00
from plugins.mpv.mpv_ipc import MPV
2025-12-20 02:12:45 -08:00
import shutil
MPV()
mpv_path = shutil.which("mpv")
_add_startup_check("ENABLED", "MPV", detail=mpv_path or "Available")
except Exception as exc:
_add_startup_check("DISABLED", "MPV", detail=str(exc))
2026-05-14 20:47:20 -07:00
for check in _collect_plugin_startup_checks(config):
_add_startup_check(
str(check.get("status") or "UNKNOWN"),
str(check.get("name") or "Plugin"),
provider=str(check.get("plugin") or ""),
instance=str(check.get("instance") or ""),
detail=str(check.get("detail") or ""),
files=check.get("files"),
)
2025-12-20 02:12:45 -08:00
2026-05-26 15:32:01 -07:00
# Plugin support checks (configured via [plugin=...])
if _has_provider(config, "florencevision"):
2025-12-30 23:19:02 -08:00
try:
2026-05-26 15:32:01 -07:00
plugin_cfg = config.get("plugin")
fv_cfg = plugin_cfg.get("florencevision") if isinstance(plugin_cfg, dict) else None
2025-12-30 23:19:02 -08:00
enabled = bool(fv_cfg.get("enabled")) if isinstance(fv_cfg, dict) else False
if not enabled:
_add_startup_check(
"DISABLED",
"FlorenceVision",
2026-05-26 15:32:01 -07:00
provider="plugin",
2025-12-30 23:19:02 -08:00
detail="Not enabled",
)
else:
from SYS.optional_deps import florencevision_missing_modules
missing = florencevision_missing_modules()
if missing:
_add_startup_check(
"DISABLED",
"FlorenceVision",
2026-05-26 15:32:01 -07:00
provider="plugin",
2025-12-30 23:19:02 -08:00
detail="Missing: " + ", ".join(missing),
)
else:
_add_startup_check(
"ENABLED",
"FlorenceVision",
2026-05-26 15:32:01 -07:00
provider="plugin",
2025-12-30 23:19:02 -08:00
detail="Ready",
)
except Exception as exc:
_add_startup_check(
"DISABLED",
"FlorenceVision",
2026-05-26 15:32:01 -07:00
provider="plugin",
2025-12-30 23:19:02 -08:00
detail=str(exc),
)
2025-12-20 02:12:45 -08:00
except Exception as exc:
2026-05-14 20:47:20 -07:00
_add_startup_check("ERROR", "STARTUP", detail=str(exc))
if startup_table.rows:
stdout_console().print()
stdout_console().print(startup_table)
2025-12-20 02:12:45 -08:00
style = Style.from_dict(
{
"cmdlet": "#ffffff",
"argument": "#3b8eea",
"value": "#9a3209",
"string": "#6d0d93",
"pipe": "#4caf50",
"selection_at": "#f1c40f",
"selection_range": "#4caf50",
"bottom-toolbar": "noreverse",
}
)
class ToolbarState:
text: str = ""
last_update_time: float = 0.0
clear_timer: Optional[threading.Timer] = None
toolbar_state = ToolbarState()
session: Optional[PromptSession] = None
def get_toolbar() -> Optional[str]:
if not toolbar_state.text or not toolbar_state.text.strip():
return None
if time.time() - toolbar_state.last_update_time > 3:
toolbar_state.text = ""
return None
return toolbar_state.text
def update_toolbar(text: str) -> None:
nonlocal session
text = text.strip()
toolbar_state.text = text
toolbar_state.last_update_time = time.time()
if toolbar_state.clear_timer:
toolbar_state.clear_timer.cancel()
toolbar_state.clear_timer = None
if text:
2025-12-29 17:05:03 -08:00
2025-12-20 02:12:45 -08:00
def clear_toolbar() -> None:
2025-12-11 12:47:30 -08:00
toolbar_state.text = ""
toolbar_state.clear_timer = None
if session is not None and hasattr(
session,
"app") and session.app.is_running:
2025-12-20 02:12:45 -08:00
session.app.invalidate()
2025-11-25 20:09:33 -08:00
2025-12-20 02:12:45 -08:00
toolbar_state.clear_timer = threading.Timer(3.0, clear_toolbar)
toolbar_state.clear_timer.daemon = True
toolbar_state.clear_timer.start()
if session is not None and hasattr(session,
"app") and session.app.is_running:
2025-12-20 02:12:45 -08:00
session.app.invalidate()
self._pipeline_executor.set_toolbar_output(update_toolbar)
completer = CmdletCompleter(config_loader=self._config_loader)
session = PromptSession(
completer=cast(Any,
completer),
2025-12-20 02:12:45 -08:00
lexer=MedeiaLexer(),
style=style,
bottom_toolbar=get_toolbar,
refresh_interval=0.5,
)
2025-11-25 20:09:33 -08:00
2026-03-18 20:17:28 -07:00
queued_inputs: List[Dict[str, Any]] = []
queued_inputs_lock = threading.Lock()
repl_queue_stop = threading.Event()
2026-03-21 14:22:48 -07:00
injected_payload: Optional[Dict[str, Any]] = None
2026-05-24 12:32:57 -07:00
repl_session_id = uuid.uuid4().hex
2026-03-18 20:17:28 -07:00
def _drain_repl_queue() -> None:
try:
pending = pop_repl_commands(self.ROOT, limit=8)
except Exception:
pending = []
if not pending:
return
with queued_inputs_lock:
queued_inputs.extend(pending)
def _inject_repl_command(payload: Dict[str, Any]) -> bool:
2026-03-21 14:22:48 -07:00
nonlocal session, injected_payload
2026-03-18 20:17:28 -07:00
command_text = str(payload.get("command") or "").strip()
source_text = str(payload.get("source") or "external").strip() or "external"
if not command_text or session is None:
return False
update_toolbar(f"queued from {source_text}: {command_text[:96]}")
app = getattr(session, "app", None)
if app is None or not getattr(app, "is_running", False):
return False
injected = False
def _apply() -> None:
2026-03-21 14:22:48 -07:00
nonlocal injected, injected_payload
2026-03-18 20:17:28 -07:00
try:
buffer = getattr(session, "default_buffer", None)
if buffer is not None:
2026-03-21 14:22:48 -07:00
with queued_inputs_lock:
injected_payload = payload
2026-03-18 20:17:28 -07:00
buffer.document = Document(text=command_text, cursor_position=len(command_text))
try:
buffer.validate_and_handle()
injected = True
return
except Exception:
pass
2026-03-21 14:22:48 -07:00
with queued_inputs_lock:
injected_payload = payload
2026-03-18 20:17:28 -07:00
app.exit(result=command_text)
injected = True
except Exception:
2026-03-21 14:22:48 -07:00
with queued_inputs_lock:
injected_payload = None
2026-03-18 20:17:28 -07:00
injected = False
try:
loop = getattr(app, "loop", None)
if loop is not None and hasattr(loop, "call_soon_threadsafe"):
loop.call_soon_threadsafe(_apply)
return True
except Exception:
pass
try:
_apply()
except Exception:
injected = False
return injected
def _queue_poll_loop() -> None:
while not repl_queue_stop.is_set():
2026-05-24 12:32:57 -07:00
try:
touch_repl_state(self.ROOT, session_id=repl_session_id)
except Exception:
pass
2026-03-18 20:17:28 -07:00
_drain_repl_queue()
with queued_inputs_lock:
next_payload = queued_inputs[0] if queued_inputs else None
if next_payload and _inject_repl_command(next_payload):
with queued_inputs_lock:
if queued_inputs and queued_inputs[0] is next_payload:
queued_inputs.pop(0)
repl_queue_stop.wait(0.25)
2026-05-24 12:32:57 -07:00
try:
touch_repl_state(self.ROOT, session_id=repl_session_id)
except Exception:
pass
2026-03-18 20:17:28 -07:00
_drain_repl_queue()
repl_queue_thread = threading.Thread(
target=_queue_poll_loop,
name="medeia-repl-queue",
daemon=True,
)
repl_queue_thread.start()
2026-05-24 12:32:57 -07:00
try:
while True:
2026-03-21 15:12:52 -07:00
try:
2026-05-24 12:32:57 -07:00
with queued_inputs_lock:
queued_payload = queued_inputs.pop(0) if queued_inputs else None
if queued_payload is not None:
source_text = str(queued_payload.get("source") or "external").strip() or "external"
user_input = str(queued_payload.get("command") or "").strip()
if user_input:
print(f"{prompt_text}{user_input} [queued:{source_text}]")
else:
user_input = ""
else:
user_input = session.prompt(prompt_text).strip()
if user_input:
with queued_inputs_lock:
if injected_payload is not None:
queued_payload = injected_payload
injected_payload = None
except (EOFError, KeyboardInterrupt):
print("He who is victorious through deceit is defeated by the truth.")
break
if not user_input:
continue
low = user_input.lower()
if low in {"exit",
"quit",
"q"}:
print("He who is victorious through deceit is defeated by the truth.")
break
if low in {"help",
"?"}:
CmdletHelp.show_cmdlet_list()
continue
pipeline_ctx_ref = None
queued_metadata = (
queued_payload.get("metadata")
if isinstance(queued_payload, dict) and isinstance(queued_payload.get("metadata"), dict)
else None
)
progress_event_callback = _build_mpv_progress_callback(queued_metadata) if queued_metadata else None
try:
from SYS import pipeline as ctx
ctx.set_current_command_text(user_input)
if hasattr(ctx, "set_progress_event_callback"):
ctx.set_progress_event_callback(progress_event_callback)
pipeline_ctx_ref = ctx
except Exception:
pipeline_ctx_ref = None
if queued_metadata:
try:
_send_mpv_callback_event(
queued_metadata,
{
"phase": "started",
"event": "command-started",
"command_text": user_input,
},
)
except Exception:
pass
execution_result: Dict[str, Any] = {
"status": "completed",
"success": True,
"error": "",
"command_text": user_input,
}
try:
from SYS.cli_syntax import validate_pipeline_text
syntax_error = validate_pipeline_text(user_input, config=config)
if syntax_error:
execution_result = {
"status": "failed",
"success": False,
"error": str(syntax_error.message or "syntax error"),
2026-03-21 15:12:52 -07:00
"command_text": user_input,
2026-05-24 12:32:57 -07:00
}
print(syntax_error.message, file=sys.stderr)
if queued_metadata:
try:
_notify_mpv_completion(queued_metadata, execution_result)
except Exception:
pass
continue
2026-03-21 15:12:52 -07:00
except Exception:
pass
2026-05-24 12:32:57 -07:00
try:
tokens = shlex.split(user_input)
except ValueError as exc:
2026-03-19 13:08:15 -07:00
execution_result = {
"status": "failed",
"success": False,
2026-05-24 12:32:57 -07:00
"error": str(exc),
2026-03-19 13:08:15 -07:00
"command_text": user_input,
}
2026-05-24 12:32:57 -07:00
print(f"Syntax error: {exc}", file=sys.stderr)
2026-03-21 14:22:48 -07:00
if queued_metadata:
try:
_notify_mpv_completion(queued_metadata, execution_result)
except Exception:
pass
2025-12-12 21:55:38 -08:00
continue
2026-05-24 12:32:57 -07:00
if not tokens:
continue
if len(tokens) == 1 and tokens[0] == "@,,":
2026-03-21 14:22:48 -07:00
try:
2026-05-24 12:32:57 -07:00
from SYS import pipeline as ctx
2025-12-20 02:12:45 -08:00
2026-05-24 12:32:57 -07:00
if ctx.restore_next_result_table():
last_table = (
ctx.get_display_table()
if hasattr(ctx,
"get_display_table") else None
2025-12-29 17:05:03 -08:00
)
2026-05-24 12:32:57 -07:00
if last_table is None:
last_table = ctx.get_last_result_table()
if last_table:
stdout_console().print()
ctx.set_current_stage_table(last_table)
stdout_console().print(last_table)
else:
items = ctx.get_last_result_items()
if items:
ctx.set_current_stage_table(None)
print(
f"Restored {len(items)} items (no table format available)"
)
else:
print("No forward history available", file=sys.stderr)
else:
print("No forward history available", file=sys.stderr)
except Exception as exc:
print(f"Error restoring next table: {exc}", file=sys.stderr)
continue
if len(tokens) == 1 and tokens[0] == "@..":
try:
from SYS import pipeline as ctx
if ctx.restore_previous_result_table():
last_table = (
ctx.get_display_table()
if hasattr(ctx,
"get_display_table") else None
)
if last_table is None:
last_table = ctx.get_last_result_table()
# Auto-refresh search-file tables when navigating back,
# so row payloads (titles/tags) reflect latest store state.
try:
src_cmd = (
getattr(last_table,
2026-05-24 12:32:57 -07:00
"source_command",
None) if last_table else None
2025-12-29 17:05:03 -08:00
)
2026-05-24 12:32:57 -07:00
if (isinstance(src_cmd,
str)
and src_cmd.lower().replace("_",
"-") == "search-file"):
src_args = (
getattr(last_table,
"source_args",
None) if last_table else None
2025-12-29 17:05:03 -08:00
)
2026-05-24 12:32:57 -07:00
base_args = list(src_args
) if isinstance(src_args,
list) else []
cleaned_args = [
str(a) for a in base_args if str(a).strip().lower()
not in {"--refresh", "-refresh"}
]
if hasattr(ctx, "set_current_command_text"):
2025-12-20 23:57:44 -08:00
try:
2026-05-24 12:32:57 -07:00
title_text = (
getattr(last_table,
"title",
None) if last_table else None
)
if isinstance(title_text,
str) and title_text.strip():
ctx.set_current_command_text(
title_text.strip()
)
else:
ctx.set_current_command_text(
" ".join(
["search-file",
*cleaned_args]
).strip()
)
2025-12-20 23:57:44 -08:00
except Exception:
pass
2026-05-24 12:32:57 -07:00
try:
self._cmdlet_executor.execute(
"search-file",
cleaned_args + ["--refresh"]
)
finally:
if hasattr(ctx, "clear_current_command_text"):
try:
ctx.clear_current_command_text()
except Exception:
pass
continue
except Exception as exc:
print(
2026-05-24 12:32:57 -07:00
f"Error refreshing search-file table: {exc}",
file=sys.stderr
)
2025-12-20 02:12:45 -08:00
2026-05-24 12:32:57 -07:00
if last_table:
stdout_console().print()
ctx.set_current_stage_table(last_table)
stdout_console().print(last_table)
else:
items = ctx.get_last_result_items()
if items:
ctx.set_current_stage_table(None)
print(
f"Restored {len(items)} items (no table format available)"
)
else:
print("No previous result table in history")
else:
print("Result table history is empty")
except Exception as exc:
print(f"Error restoring previous result table: {exc}")
continue
try:
if "|" in tokens or (tokens and tokens[0].startswith("@")):
self._pipeline_executor.execute_tokens(tokens)
2025-11-25 20:09:33 -08:00
else:
2026-05-24 12:32:57 -07:00
cmd_name = tokens[0].replace("_", "-").lower()
is_help = any(
arg in {"-help",
"--help",
"-h"} for arg in tokens[1:]
)
if is_help:
CmdletHelp.show_cmdlet_help(cmd_name)
else:
self._cmdlet_executor.execute(cmd_name, tokens[1:])
finally:
if pipeline_ctx_ref and hasattr(pipeline_ctx_ref, "get_last_execution_result"):
2026-03-21 15:12:52 -07:00
try:
2026-05-24 12:32:57 -07:00
latest = pipeline_ctx_ref.get_last_execution_result()
if isinstance(latest, dict) and latest:
execution_result = latest
2026-03-21 15:12:52 -07:00
except Exception:
pass
2026-05-24 12:32:57 -07:00
if queued_metadata:
try:
_notify_mpv_completion(queued_metadata, execution_result)
except Exception:
pass
if pipeline_ctx_ref:
pipeline_ctx_ref.clear_current_command_text()
if hasattr(pipeline_ctx_ref, "set_progress_event_callback"):
try:
pipeline_ctx_ref.set_progress_event_callback(None)
except Exception:
pass
finally:
repl_queue_stop.set()
try:
repl_queue_thread.join(timeout=1.0)
except Exception:
pass
try:
clear_repl_state(self.ROOT)
except Exception:
pass