This commit is contained in:
nose
2025-12-16 01:45:01 -08:00
parent a03eb0d1be
commit 9873280f0e
36 changed files with 4911 additions and 1225 deletions

537
CLI.py
View File

@@ -207,16 +207,70 @@ def _load_cli_config() -> Dict[str, Any]:
return {}
def _get_table_title_for_command(cmd_name: str, emitted_items: Optional[List[Any]] = None) -> str:
def _get_table_title_for_command(
cmd_name: str,
emitted_items: Optional[List[Any]] = None,
cmd_args: Optional[List[str]] = None,
) -> str:
"""Generate a dynamic table title based on the command and emitted items.
Args:
cmd_name: The command name (e.g., 'search-file', 'get-tag', 'get-file')
emitted_items: The items being displayed
cmd_args: Arguments passed to the command (when available)
Returns:
A descriptive title for the result table
"""
# Prefer argument-aware titles where possible so table history is self-describing.
if cmd_name in ('search-provider', 'search_provider') and cmd_args:
# Support both positional form:
# search-provider <provider> <query>
# and flag form:
# search-provider -provider <provider> <query>
provider: str = ""
query: str = ""
tokens = [str(a) for a in (cmd_args or [])]
pos: List[str] = []
i = 0
while i < len(tokens):
low = tokens[i].lower()
if low in {"-provider", "--provider"} and i + 1 < len(tokens):
provider = str(tokens[i + 1]).strip()
i += 2
continue
if low in {"-query", "--query"} and i + 1 < len(tokens):
query = str(tokens[i + 1]).strip()
i += 2
continue
if low in {"-limit", "--limit"} and i + 1 < len(tokens):
i += 2
continue
if not str(tokens[i]).startswith("-"):
pos.append(str(tokens[i]))
i += 1
if not provider and pos:
provider = str(pos[0]).strip()
pos = pos[1:]
if not query and pos:
query = " ".join(pos).strip()
if not provider or not query:
# Fall back to generic mapping below.
provider = ""
query = ""
provider_lower = provider.lower()
if provider_lower == 'youtube':
provider_label = 'Youtube'
elif provider_lower == 'openlibrary':
provider_label = 'OpenLibrary'
else:
provider_label = provider[:1].upper() + provider[1:] if provider else 'Provider'
if provider and query:
return f"{provider_label}: {query}".strip().rstrip(':')
# Mapping of commands to title templates
title_map = {
'search-file': 'Results',
@@ -518,6 +572,24 @@ def _get_arg_choices(cmd_name: str, arg_name: str) -> List[str]:
# Dynamic search providers
if normalized_arg == "provider":
try:
canonical_cmd = (cmd_name or "").replace("_", "-").lower()
# cmdlet-aware provider choices:
# - search-provider: search providers
# - add-file: file providers (0x0, matrix)
if canonical_cmd in {"search-provider"}:
from ProviderCore.registry import list_search_providers
providers = list_search_providers(_load_cli_config())
available = [name for name, is_ready in providers.items() if is_ready]
return sorted(available) if available else sorted(providers.keys())
if canonical_cmd in {"add-file"}:
from ProviderCore.registry import list_file_providers
providers = list_file_providers(_load_cli_config())
available = [name for name, is_ready in providers.items() if is_ready]
return sorted(available) if available else sorted(providers.keys())
# Default behavior (legacy): merge search providers and metadata providers.
from ProviderCore.registry import list_search_providers
providers = list_search_providers(_load_cli_config())
available = [name for name, is_ready in providers.items() if is_ready]
@@ -570,6 +642,7 @@ if (
"""Generate completions for the current input."""
text = document.text_before_cursor
tokens = text.split()
ends_with_space = bool(text) and text[-1].isspace()
# Respect pipeline stages: only use tokens after the last '|'
last_pipe = -1
@@ -586,6 +659,31 @@ if (
# Single token at this stage -> suggest command names/keywords
if len(stage_tokens) == 1:
current = stage_tokens[0].lower()
# If the user has finished typing the command and added a space,
# complete that command's flags (or sub-choices) instead of command names.
if ends_with_space:
cmd_name = current.replace("_", "-")
if cmd_name in {"help"}:
for cmd in self.cmdlet_names:
yield CompletionType(cmd, start_position=0)
return
arg_names = _get_cmdlet_args(cmd_name)
logical_seen: Set[str] = set()
for arg in arg_names:
arg_low = arg.lower()
if arg_low.startswith("--"):
continue
logical = arg.lstrip("-").lower()
if logical in logical_seen:
continue
yield CompletionType(arg, start_position=0)
logical_seen.add(logical)
yield CompletionType("-help", start_position=0)
return
for cmd in self.cmdlet_names:
if cmd.startswith(current):
yield CompletionType(cmd, start_position=-len(current))
@@ -596,8 +694,12 @@ if (
# Otherwise treat first token of stage as command and complete its args
cmd_name = stage_tokens[0].replace("_", "-").lower()
current_token = stage_tokens[-1].lower()
prev_token = stage_tokens[-2].lower() if len(stage_tokens) > 1 else ""
if ends_with_space:
current_token = ""
prev_token = stage_tokens[-1].lower()
else:
current_token = stage_tokens[-1].lower()
prev_token = stage_tokens[-2].lower() if len(stage_tokens) > 1 else ""
choices = _get_arg_choices(cmd_name, prev_token)
if choices:
@@ -611,22 +713,29 @@ if (
for arg in arg_names:
arg_low = arg.lower()
prefer_single_dash = current_token in {"", "-"}
# If the user has only typed '-', prefer single-dash flags (e.g. -url)
# and avoid suggesting both -name and --name for the same logical arg.
if current_token == "-" and arg_low.startswith("--"):
if prefer_single_dash and arg_low.startswith("--"):
continue
logical = arg.lstrip("-").lower()
if current_token == "-" and logical in logical_seen:
if prefer_single_dash and logical in logical_seen:
continue
if arg_low.startswith(current_token):
yield CompletionType(arg, start_position=-len(current_token))
if current_token == "-":
if prefer_single_dash:
logical_seen.add(logical)
if "--help".startswith(current_token):
yield CompletionType("--help", start_position=-len(current_token))
# Help completion: prefer -help unless user explicitly starts '--'
if current_token.startswith("--"):
if "--help".startswith(current_token):
yield CompletionType("--help", start_position=-len(current_token))
else:
if "-help".startswith(current_token):
yield CompletionType("-help", start_position=-len(current_token))
async def get_completions_async(self, document: Document, complete_event): # type: ignore[override]
for completion in self.get_completions(document, complete_event):
@@ -681,6 +790,45 @@ def _create_cmdlet_cli():
return None
app = typer.Typer(help="Medeia-Macina CLI")
def _complete_search_provider(ctx, param, incomplete: str): # pragma: no cover
"""Shell completion for --provider values on the Typer search-provider command."""
try:
import click
from click.shell_completion import CompletionItem
except Exception:
return []
try:
from ProviderCore.registry import list_search_providers
providers = list_search_providers(_load_cli_config())
available = [n for n, ok in (providers or {}).items() if ok]
choices = sorted(available) if available else sorted((providers or {}).keys())
except Exception:
choices = []
inc = (incomplete or "").lower()
out = []
for name in choices:
if not name:
continue
if name.lower().startswith(inc):
out.append(CompletionItem(name))
return out
@app.command("search-provider")
def search_provider(
provider: str = typer.Option(
..., "--provider", "-p",
help="Provider name (bandcamp, libgen, soulseek, youtube)",
shell_complete=_complete_search_provider,
),
query: str = typer.Argument(..., help="Search query (quote for spaces)"),
limit: int = typer.Option(50, "--limit", "-l", help="Maximum results to return"),
):
"""Search external providers (Typer wrapper around the cmdlet)."""
# Delegate to the existing cmdlet so behavior stays consistent.
_execute_cmdlet("search-provider", ["-provider", provider, query, "-limit", str(limit)])
@app.command("pipeline")
def pipeline(
@@ -804,6 +952,18 @@ def _create_cmdlet_cli():
block = provider_cfg.get(str(name).strip().lower())
return isinstance(block, dict) and bool(block)
def _ping_url(url: str, timeout: float = 3.0) -> tuple[bool, str]:
try:
from API.HTTP import HTTPClient
with HTTPClient(timeout=timeout, retries=1) as client:
resp = client.get(url, allow_redirects=True)
code = int(getattr(resp, "status_code", 0) or 0)
ok = 200 <= code < 500
return ok, f"{url} (HTTP {code})"
except Exception as exc:
return False, f"{url} ({type(exc).__name__})"
# Load config and initialize debug logging
config = {}
try:
@@ -894,6 +1054,169 @@ def _create_cmdlet_cli():
detail = (url_val + (" - " if url_val else "")) + (err or "Unavailable")
_add_startup_check(status, name_key, "hydrusnetwork", detail)
# Configured providers (dynamic): show any [provider=...] blocks.
# This complements store checks and avoids hardcoding per-provider rows.
provider_cfg = config.get("provider") if isinstance(config, dict) else None
if isinstance(provider_cfg, dict) and provider_cfg:
try:
from ProviderCore.registry import (
list_search_providers,
list_file_providers,
)
except Exception:
list_search_providers = None # type: ignore
list_file_providers = None # type: ignore
try:
from Provider.metadata_provider import list_metadata_providers
except Exception:
list_metadata_providers = None # type: ignore
search_availability = {}
file_availability = {}
meta_availability = {}
try:
if list_search_providers is not None:
search_availability = list_search_providers(config) or {}
except Exception:
search_availability = {}
try:
if list_file_providers is not None:
file_availability = list_file_providers(config) or {}
except Exception:
file_availability = {}
try:
if list_metadata_providers is not None:
meta_availability = list_metadata_providers(config) or {}
except Exception:
meta_availability = {}
def _provider_display_name(key: str) -> str:
k = (key or "").strip()
low = k.lower()
if low == "openlibrary":
return "OpenLibrary"
if low == "alldebrid":
return "AllDebrid"
if low == "youtube":
return "YouTube"
return k[:1].upper() + k[1:] if k else "Provider"
# Avoid duplicating the existing Matrix row.
already_checked = {"matrix"}
def _default_provider_ping_targets(provider_key: str) -> list[str]:
prov = (provider_key or "").strip().lower()
if prov == "openlibrary":
return ["https://openlibrary.org"]
if prov == "youtube":
return ["https://www.youtube.com"]
if prov == "bandcamp":
return ["https://bandcamp.com"]
if prov == "libgen":
try:
from Provider.libgen import MIRRORS
mirrors = [str(x).rstrip("/") for x in (MIRRORS or []) if str(x).strip()]
return [m + "/json.php" for m in mirrors]
except Exception:
return []
return []
def _ping_first(urls: list[str]) -> tuple[bool, str]:
for u in urls:
ok, detail = _ping_url(u)
if ok:
return True, detail
if urls:
ok, detail = _ping_url(urls[0])
return ok, detail
return False, "No ping target"
for provider_name in provider_cfg.keys():
prov = str(provider_name or "").strip().lower()
if not prov or prov in already_checked:
continue
display = _provider_display_name(prov)
# Special-case AllDebrid to show a richer detail and validate connectivity.
if prov == "alldebrid":
try:
from Provider.alldebrid import _get_debrid_api_key # type: ignore
api_key = _get_debrid_api_key(config)
if not api_key:
_add_startup_check("DISABLED", display, prov, "Not configured")
else:
from API.alldebrid import AllDebridClient
client = AllDebridClient(api_key)
base_url = str(getattr(client, "base_url", "") or "").strip()
_add_startup_check("ENABLED", display, prov, base_url or "Connected")
except Exception as exc:
_add_startup_check("DISABLED", display, prov, str(exc))
continue
is_known = False
ok = None
if prov in search_availability:
is_known = True
ok = bool(search_availability.get(prov))
elif prov in file_availability:
is_known = True
ok = bool(file_availability.get(prov))
elif prov in meta_availability:
is_known = True
ok = bool(meta_availability.get(prov))
if not is_known:
_add_startup_check("UNKNOWN", display, prov, "Not registered")
else:
# For non-login providers, include a lightweight URL reachability check.
detail = "Configured" if ok else "Not configured"
ping_targets = _default_provider_ping_targets(prov)
if ping_targets:
ping_ok, ping_detail = _ping_first(ping_targets)
if ok:
detail = ping_detail
else:
detail = (detail + " | " + ping_detail) if ping_detail else detail
_add_startup_check("ENABLED" if ok else "DISABLED", display, prov, detail)
already_checked.add(prov)
# Also show default non-login providers even if they aren't configured.
# This helps users know what's available/reachable out of the box.
default_search_providers = ["openlibrary", "libgen", "youtube", "bandcamp"]
for prov in default_search_providers:
if prov in already_checked:
continue
display = _provider_display_name(prov)
ok = bool(search_availability.get(prov)) if prov in search_availability else False
ping_targets = _default_provider_ping_targets(prov)
ping_ok, ping_detail = _ping_first(ping_targets) if ping_targets else (False, "No ping target")
detail = ping_detail if ping_detail else ("Available" if ok else "Unavailable")
# If the provider isn't even import/dep available, show that first.
if not ok:
detail = ("Unavailable" + (f" | {ping_detail}" if ping_detail else ""))
_add_startup_check("ENABLED" if (ok and ping_ok) else "DISABLED", display, prov, detail)
already_checked.add(prov)
# Default file providers (no login): 0x0
if "0x0" not in already_checked:
ok = bool(file_availability.get("0x0")) if "0x0" in file_availability else False
ping_ok, ping_detail = _ping_url("https://0x0.st")
detail = ping_detail
if not ok:
detail = ("Unavailable" + (f" | {ping_detail}" if ping_detail else ""))
_add_startup_check("ENABLED" if (ok and ping_ok) else "DISABLED", "0x0", "0x0", detail)
already_checked.add("0x0")
if _has_provider(config, "matrix"):
# Matrix availability is validated by Provider.matrix.Matrix.__init__.
try:
@@ -1397,9 +1720,9 @@ def _execute_pipeline(tokens: list):
if table_for_stage:
ctx.set_current_stage_table(table_for_stage)
# Special check for table-specific behavior BEFORE command expansion
# If we are selecting from a YouTube or Soulseek search, we want to force auto-piping to .pipe
# instead of trying to expand to a command (which search-file doesn't support well for re-execution)
# Special check for table-specific behavior BEFORE command expansion.
# For some provider tables, we prefer item-based selection over command expansion,
# and may auto-append a sensible follow-up stage (e.g. YouTube -> download-media).
source_cmd = ctx.get_current_stage_table_source_command()
source_args = ctx.get_current_stage_table_source_args()
@@ -1409,7 +1732,7 @@ def _execute_pipeline(tokens: list):
# Logic based on table type
if table_type == 'youtube' or table_type == 'soulseek':
# Force fallback to item-based selection so we can auto-pipe
# Force fallback to item-based selection so we can auto-append a follow-up stage
command_expanded = False
# Skip the command expansion block below
elif source_cmd == 'search-file' and source_args and 'youtube' in source_args:
@@ -1493,18 +1816,21 @@ def _execute_pipeline(tokens: list):
if not stages:
if table_type == 'youtube':
print(f"Auto-piping YouTube selection to .pipe")
stages.append(['.pipe'])
print(f"Auto-running YouTube selection via download-media")
stages.append(['download-media'])
elif table_type == 'soulseek':
print(f"Auto-piping Soulseek selection to download-file")
stages.append(['download-file'])
elif table_type == 'openlibrary':
print(f"Auto-piping OpenLibrary selection to download-file")
stages.append(['download-file'])
elif table_type == 'libgen':
print(f"Auto-piping Libgen selection to download-file")
stages.append(['download-file'])
elif source_cmd == 'search-file' and source_args and 'youtube' in source_args:
# Legacy check
print(f"Auto-piping YouTube selection to .pipe")
stages.append(['.pipe'])
print(f"Auto-running YouTube selection via download-media")
stages.append(['download-media'])
else:
# If the user is piping a provider selection into additional stages (e.g. add-file),
# automatically insert the appropriate download stage so @N is "logical".
@@ -1513,6 +1839,12 @@ def _execute_pipeline(tokens: list):
if table_type == 'soulseek' and first_cmd not in ('download-file', 'download-media', 'download_media', '.pipe'):
print(f"Auto-inserting download-file after Soulseek selection")
stages.insert(0, ['download-file'])
if table_type == 'youtube' and first_cmd not in ('download-media', 'download_media', 'download-file', '.pipe'):
print(f"Auto-inserting download-media after YouTube selection")
stages.insert(0, ['download-media'])
if table_type == 'libgen' and first_cmd not in ('download-file', 'download-media', 'download_media', '.pipe'):
print(f"Auto-inserting download-file after Libgen selection")
stages.insert(0, ['download-file'])
else:
print(f"No items matched selection in pipeline\n")
@@ -1567,6 +1899,15 @@ def _execute_pipeline(tokens: list):
# Check if piped_result contains format objects and we have expansion info
source_cmd = ctx.get_current_stage_table_source_command()
source_args = ctx.get_current_stage_table_source_args()
# If selecting from a YouTube results table and this is the last stage,
# auto-run download-media instead of leaving a bare selection.
current_table = ctx.get_current_stage_table()
table_type = current_table.table if current_table and hasattr(current_table, 'table') else None
if table_type == 'youtube' and stage_index + 1 >= len(stages):
print(f"Auto-running YouTube selection via download-media")
stages.append(['download-media', *stage_args])
should_expand_to_command = False
if source_cmd == '.pipe' or source_cmd == '.adjective':
should_expand_to_command = True
@@ -1574,11 +1915,11 @@ def _execute_pipeline(tokens: list):
# When piping playlist rows to another cmdlet, prefer item-based selection
should_expand_to_command = False
elif source_cmd == 'search-file' and source_args and 'youtube' in source_args:
# Special case for youtube search results: @N expands to .pipe
# Legacy behavior: selection at end should run a sensible follow-up.
if stage_index + 1 >= len(stages):
# Only auto-pipe if this is the last stage
print(f"Auto-piping YouTube selection to .pipe")
stages.append(['.pipe'])
print(f"Auto-running YouTube selection via download-media")
stages.append(['download-media'])
# Force should_expand_to_command to False so we fall through to filtering
should_expand_to_command = False
@@ -1671,6 +2012,26 @@ def _execute_pipeline(tokens: list):
piped_result = filtered_pipe_objs if len(filtered_pipe_objs) > 1 else filtered_pipe_objs[0]
print(f"Selected {len(filtered)} item(s) using {cmd_name}")
# If selecting YouTube results and there are downstream stages,
# insert download-media so subsequent cmdlets receive a local temp file.
try:
current_table = ctx.get_current_stage_table()
table_type = current_table.table if current_table and hasattr(current_table, 'table') else None
except Exception:
table_type = None
if table_type == 'youtube' and stage_index + 1 < len(stages):
next_cmd = stages[stage_index + 1][0] if stages[stage_index + 1] else None
if next_cmd not in ('download-media', 'download_media', 'download-file', '.pipe'):
print("Auto-inserting download-media after YouTube selection")
stages.insert(stage_index + 1, ['download-media'])
if table_type == 'libgen' and stage_index + 1 < len(stages):
next_cmd = stages[stage_index + 1][0] if stages[stage_index + 1] else None
if next_cmd not in ('download-file', 'download-media', 'download_media', '.pipe'):
print("Auto-inserting download-file after Libgen selection")
stages.insert(stage_index + 1, ['download-file'])
# If selection is the last stage and looks like a provider result,
# auto-initiate the borrow/download flow.
if stage_index + 1 >= len(stages):
@@ -1699,6 +2060,11 @@ def _execute_pipeline(tokens: list):
if provider is not None:
print("Auto-downloading selection via download-file")
stages.append(["download-file"])
else:
# Fallback: if we know the current table type, prefer a sensible default.
if table_type == 'libgen':
print("Auto-downloading Libgen selection via download-file")
stages.append(["download-file"])
continue
else:
print(f"No items matched selection {cmd_name}\n")
@@ -1719,6 +2085,13 @@ def _execute_pipeline(tokens: list):
pipeline_status = "failed"
pipeline_error = f"Unknown command {cmd_name}"
return
# Prevent stale tables (e.g., a previous download-media format picker)
# from leaking into subsequent stages and being displayed again.
try:
ctx.set_current_stage_table(None)
except Exception:
pass
debug(f"[pipeline] Stage {stage_index}: cmd_name={cmd_name}, cmd_fn type={type(cmd_fn)}, piped_result type={type(piped_result)}, stage_args={stage_args}")
@@ -1758,7 +2131,7 @@ def _execute_pipeline(tokens: list):
if is_last_stage:
# Last stage - display results
if RESULT_TABLE_AVAILABLE and ResultTable is not None and pipeline_ctx.emits:
table_title = _get_table_title_for_command(cmd_name, pipeline_ctx.emits)
table_title = _get_table_title_for_command(cmd_name, pipeline_ctx.emits, stage_args)
# Only set source_command for search/filter commands (not display-only or action commands)
# This preserves context so @N refers to the original search, not intermediate results
@@ -1776,7 +2149,8 @@ def _execute_pipeline(tokens: list):
self_managing_commands = {
'get-tag', 'get_tag', 'tags',
'get-url', 'get_url',
'search-file', 'search_file'
'search-file', 'search_file',
'search-provider', 'search_provider'
}
overlay_table = ctx.get_display_table() if hasattr(ctx, 'get_display_table') else None
@@ -1858,6 +2232,24 @@ def _execute_pipeline(tokens: list):
else:
# No output from this stage. If it presented a selectable table (e.g., format list), pause
# and stash the remaining pipeline so @N can resume with the selection applied.
if is_last_stage:
# Last stage with no emitted items: only display a *current* selectable table set by
# the cmdlet (e.g., download-media format picker). Do NOT fall back to last_result_table,
# which may be stale from a previous command.
stage_table_source = ctx.get_current_stage_table_source_command()
row_has_selection = ctx.get_current_stage_table_row_selection_args(0) is not None
stage_table = ctx.get_current_stage_table()
if not stage_table and hasattr(ctx, 'get_display_table'):
stage_table = ctx.get_display_table()
if RESULT_TABLE_AVAILABLE and stage_table is not None and stage_table_source and row_has_selection:
try:
print()
print(stage_table.format_plain())
except Exception:
pass
continue
if not is_last_stage:
stage_table_source = ctx.get_current_stage_table_source_command()
row_has_selection = ctx.get_current_stage_table_row_selection_args(0) is not None
@@ -2016,39 +2408,69 @@ def _execute_cmdlet(cmd_name: str, args: list):
# Load config relative to CLI root
config = _load_cli_config()
# Check for @ selection syntax in arguments
# Extract @N, @N-M, @{N,M,P} syntax and remove from args
filtered_args = []
selected_indices = []
for arg in args:
if arg.startswith('@'):
# Parse selection: @2, @2-5, @{1,3,5}
selection_str = arg[1:] # Remove @
try:
if '{' in selection_str and '}' in selection_str:
# @{1,3,5} format
selection_str = selection_str.strip('{}')
selected_indices = [int(x.strip()) - 1 for x in selection_str.split(',')]
elif '-' in selection_str:
# @2-5 format
parts = selection_str.split('-')
start = int(parts[0]) - 1
end = int(parts[1])
selected_indices = list(range(start, end))
else:
# @2 format
selected_indices = [int(selection_str) - 1]
except (ValueError, IndexError):
# Invalid format, treat as regular arg
# Special case: @"string" should be treated as "string" (stripping @)
# This allows adding new items via @"New Item" syntax
if selection_str.startswith('"') or selection_str.startswith("'"):
filtered_args.append(selection_str.strip('"\''))
else:
filtered_args.append(arg)
else:
# Check for @ selection syntax in arguments.
# IMPORTANT: support using @N as a VALUE for a value-taking flag (e.g. add-relationship -king @1).
# Only treat @ tokens as selection when they are NOT in a value position.
filtered_args: list[str] = []
selected_indices: list[int] = []
select_all = False
# Build a set of flag tokens that consume a value for this cmdlet.
# We use cmdlet metadata so we don't break patterns like: get-tag -raw @1 (where -raw is a flag).
value_flags: set[str] = set()
try:
meta = _catalog_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:
try:
spec_type = str(getattr(spec, "type", "string") or "string").strip().lower()
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:
continue
except Exception:
value_flags = set()
for i, arg in enumerate(args):
if isinstance(arg, str) and arg.startswith('@'):
prev = str(args[i - 1]).lower() if i > 0 else ""
# If this @ token is the value for a value-taking flag, keep it.
if prev in value_flags:
filtered_args.append(arg)
continue
# Special case: @"string" should be treated as "string" (stripping @)
# This allows adding new items via @"New Item" syntax
if len(arg) >= 2 and (arg[1] == '"' or arg[1] == "'"):
filtered_args.append(arg[1:].strip('"\''))
continue
# Parse selection: @2, @2-5, @{1,3,5}, @3,5,7, @3-6,8, @*
if arg.strip() == "@*":
select_all = True
continue
selection = _parse_selection_syntax(arg)
if selection is not None:
zero_based = sorted(i - 1 for i in selection if isinstance(i, int) and i > 0)
selected_indices.extend([idx for idx in zero_based if idx not in selected_indices])
continue
# Not a valid selection, treat as regular arg
filtered_args.append(arg)
else:
filtered_args.append(str(arg))
# Get piped items from previous command results
piped_items = ctx.get_last_result_items()
@@ -2056,7 +2478,9 @@ def _execute_cmdlet(cmd_name: str, args: list):
# Create result object - pass full list (or filtered list if @ selection used) to cmdlet
result = None
if piped_items:
if selected_indices:
if select_all:
result = piped_items
elif selected_indices:
# Filter to selected indices only
result = [piped_items[idx] for idx in selected_indices if 0 <= idx < len(piped_items)]
else:
@@ -2101,7 +2525,7 @@ def _execute_cmdlet(cmd_name: str, args: list):
ctx.set_last_result_items_only(pipeline_ctx.emits)
else:
# Try to format as a table if we have search results
table_title = _get_table_title_for_command(cmd_name, pipeline_ctx.emits)
table_title = _get_table_title_for_command(cmd_name, pipeline_ctx.emits, filtered_args)
# Only set source_command for search/filter commands (not display-only or action commands)
# This preserves context so @N refers to the original search, not intermediate results
@@ -2118,7 +2542,8 @@ def _execute_cmdlet(cmd_name: str, args: list):
# Commands that manage their own table/history state (e.g. get-tag)
self_managing_commands = {
'get-tag', 'get_tag', 'tags',
'search-file', 'search_file'
'search-file', 'search_file',
'search-provider', 'search_provider'
}
if cmd_name in self_managing_commands: