CmdForge/src/cmdforge/cli/picker.py

553 lines
18 KiB
Python

"""Interactive fuzzy tool picker for CmdForge - inline dropdown style."""
import sys
import os
import select
import tty
import termios
from concurrent.futures import ThreadPoolExecutor
from typing import List, Tuple, Optional
from dataclasses import dataclass
from ..tool import list_tools, load_tool
@dataclass
class PickerResult:
"""Result from the picker."""
tool_name: str
arguments: dict
# ANSI escape codes
CLEAR_LINE = "\033[2K"
MOVE_UP = "\033[A"
HIDE_CURSOR = "\033[?25l"
SHOW_CURSOR = "\033[?25h"
BOLD = "\033[1m"
DIM = "\033[2m"
CYAN = "\033[36m"
YELLOW = "\033[33m"
GREEN = "\033[32m"
RESET = "\033[0m"
MAX_VISIBLE = 8 # Show at most 8 items
_registry_cache = {}
# Output stream for UI (stderr when stdout is piped, stdout otherwise)
_ui_out = None
def _write(text: str):
"""Write to UI output stream."""
_ui_out.write(text)
_ui_out.flush()
def fuzzy_match(query: str, text: str) -> Tuple[bool, int]:
"""Fuzzy match with scoring."""
if not query:
return True, 0
query = query.lower()
text = text.lower()
if query in text:
if text.startswith(query):
return True, 1000 + len(query)
return True, 500 + len(query)
# Character-by-character fuzzy match
qi = 0
score = 0
for i, c in enumerate(text):
if qi < len(query) and c == query[qi]:
score += 10 if i == 0 or text[i-1] in ' -_' else 1
qi += 1
return (qi == len(query), score) if qi == len(query) else (False, 0)
def get_tools() -> List[dict]:
"""Get all tools with info."""
tools = []
for name in list_tools():
tool = load_tool(name)
if tool:
tools.append({
"name": name,
"desc": (tool.description or "")[:50],
"args": [{
"flag": a.flag,
"default": a.default or "",
"desc": a.description or ""
} for a in tool.arguments],
"deprecated": tool.deprecated,
"deprecated_message": tool.deprecated_message,
"replacement": tool.replacement,
})
return sorted(tools, key=lambda t: t["name"])
def search_registry(query: str) -> List[dict]:
"""Search the registry for tools matching the query.
Returns a list of dicts with name, desc, and registry metadata.
Registry search is public; authentication is not required.
"""
if not query or len(query) < 2:
return []
normalized = query.strip().lower()
if normalized in _registry_cache:
return [dict(item) for item in _registry_cache[normalized]]
try:
from ..registry_client import RegistryError, get_client
client = get_client()
client.timeout = min(getattr(client, "timeout", 3), 3)
client.max_retries = 1
semantic = client.semantic_search(query, limit=5)
if semantic.get("available") and semantic.get("data"):
items = semantic["data"]
else:
results = client.search_tools(query, per_page=5)
items = results.data if hasattr(results, "data") else results
found = [
{
"name": f"{item.get('owner', '')}/{item.get('name', '')}",
"desc": (item.get("description") or "")[:50],
"args": [],
"registry": True,
"downloads": item.get("downloads", 0),
"relevance": item.get("similarity", item.get("score")),
"quality_score": item.get("quality_score"),
"quality_coverage": item.get("quality_coverage"),
}
for item in items
]
_registry_cache[normalized] = found
return [dict(item) for item in found]
except RegistryError:
return []
def _install_registry_selection(tool: dict) -> PickerResult:
"""Install a registry result and retain its qualified identity for execution."""
from ..resolver import install_from_registry
install_from_registry(tool["name"])
return PickerResult(tool["name"], {})
def _local_selection(tool: dict, arguments: Optional[dict] = None) -> PickerResult:
if tool.get("deprecated"):
guidance = tool.get("deprecated_message") or "This tool is no longer maintained."
if tool.get("replacement"):
guidance += f" Use '{tool['replacement']}' instead."
_write(f"{YELLOW}Warning: {guidance}{RESET}\n")
return PickerResult(tool["name"], arguments or {})
class TTYInput:
"""Read from /dev/tty for keyboard input, even when stdin is piped."""
def __init__(self):
self.tty = None
self.fd = None
self.old_settings = None
def __enter__(self):
self.tty = open('/dev/tty', 'r')
self.fd = self.tty.fileno()
self.old_settings = termios.tcgetattr(self.fd)
return self
def __exit__(self, *args):
if self.old_settings:
termios.tcsetattr(self.fd, termios.TCSANOW, self.old_settings)
if self.tty:
self.tty.close()
def getch(self, timeout: Optional[float] = None):
"""Read a single character."""
tty.setraw(self.fd)
try:
if timeout is not None:
ready, _, _ = select.select([self.tty], [], [], timeout)
if not ready:
return None
ch = self.tty.read(1)
if ch == '\x1b':
# Read escape sequence - bytes arrive together from terminal
ch2 = self.tty.read(1)
if ch2 == '[':
ch3 = self.tty.read(1)
if ch3 == 'A': return 'UP'
if ch3 == 'B': return 'DOWN'
if ch3 == 'C': return 'RIGHT'
if ch3 == 'D': return 'LEFT'
return ch
finally:
termios.tcsetattr(self.fd, termios.TCSANOW, self.old_settings)
def clear_dropdown(n_lines: int):
"""Clear the dropdown lines we drew."""
for _ in range(n_lines):
_write(MOVE_UP + CLEAR_LINE)
_write('\r')
def run_picker(tty_input: TTYInput) -> Optional[PickerResult]:
"""Run inline picker."""
tools = get_tools()
query = ""
selected = 0
scroll = 0
last_drawn = 0
registry_results = {}
registry_future = None
registry_future_query = None
registry_executor = ThreadPoolExecutor(max_workers=1)
_write(HIDE_CURSOR)
try:
while True:
if registry_future is not None and registry_future.done():
registry_results[registry_future_query] = registry_future.result()
registry_future = None
registry_future_query = None
# Filter
matches = []
for t in tools:
ok, score = fuzzy_match(query, t["name"])
if not ok:
ok, score = fuzzy_match(query, t["desc"])
score = score // 2
if ok:
matches.append((t, score))
matches.sort(key=lambda x: -x[1])
filtered = [m[0] for m in matches]
# If no local matches, search registry
if query and len(query) >= 2 and not filtered:
normalized_query = query.strip().lower()
found = registry_results.get(normalized_query, [])
if registry_future is None and normalized_query not in registry_results:
registry_future_query = normalized_query
registry_future = registry_executor.submit(
search_registry, query
)
for rt in found:
rt["_registry"] = True
filtered = found
if selected >= len(filtered):
selected = max(0, len(filtered) - 1)
# Adjust scroll to keep selection visible
if selected < scroll:
scroll = selected
elif selected >= scroll + MAX_VISIBLE:
scroll = selected - MAX_VISIBLE + 1
# Clear previous
if last_drawn:
clear_dropdown(last_drawn)
# Draw
visible = filtered[scroll:scroll + MAX_VISIBLE]
lines = []
# Query line
prompt = f"{DIM}>{RESET} {YELLOW}{query}{RESET}"
lines.append(prompt)
# Items
for i, t in enumerate(visible):
actual_idx = scroll + i
is_registry = t.get("_registry", False) or t.get("registry", False)
is_deprecated = t.get("deprecated", False)
if actual_idx == selected:
prefix = f"{CYAN}{BOLD}{t['name']}{RESET}"
else:
prefix = f" {t['name']}"
# Add registry marker
if is_registry:
prefix += f" {YELLOW}[registry]{RESET}"
if t.get("quality_score") is not None:
prefix += f" Q{t['quality_score']}"
if t.get("quality_coverage") is not None:
prefix += f"/{t['quality_coverage']}%"
if t.get("relevance") is not None:
prefix += f" R{float(t['relevance']):.2f}"
# Add deprecation marker
if is_deprecated:
prefix += f" {YELLOW}[deprecated]{RESET}"
# Add arg indicator
if t.get('args'):
prefix += f" {GREEN}{RESET}"
# Add description
if t['desc']:
prefix += f" {DIM}- {t['desc']}{RESET}"
lines.append(prefix)
# Print
_write('\n'.join(lines) + '\n')
last_drawn = len(lines)
# Input
ch = tty_input.getch(
timeout=0.1 if registry_future is not None else None
)
if ch is None:
continue
if ch in ('\r', '\n'): # Enter - run
if filtered:
selected_tool = filtered[selected]
clear_dropdown(last_drawn)
_write(SHOW_CURSOR)
# If it's a registry tool, install it first
if selected_tool.get("_registry") or selected_tool.get("registry"):
_write(f"{YELLOW}Installing {selected_tool['name']}...{RESET}\n")
try:
return _install_registry_selection(selected_tool)
except Exception as exc:
_write(f"Install failed: {exc}\n")
return None
return _local_selection(selected_tool)
elif ch == '\t': # Tab - configure args or run
if filtered:
tool = filtered[selected]
clear_dropdown(last_drawn)
if tool.get("_registry") or tool.get("registry"):
_write(f"{YELLOW}Installing {tool['name']}...{RESET}\n")
try:
_write(SHOW_CURSOR)
return _install_registry_selection(tool)
except Exception as exc:
_write(f"Install failed: {exc}\n")
_write(SHOW_CURSOR)
return None
if tool['args']:
args = pick_args(tty_input, tool)
_write(SHOW_CURSOR)
return _local_selection(tool, args) if args is not None else None
_write(SHOW_CURSOR)
return _local_selection(tool)
elif ch == '\x1b' or ch == '\x03': # Esc or Ctrl+C
clear_dropdown(last_drawn)
_write(SHOW_CURSOR)
return None
elif ch == 'UP':
selected = max(0, selected - 1)
elif ch == 'DOWN':
selected = min(len(filtered) - 1, selected + 1)
elif ch == '\x7f' or ch == '\b': # Backspace
query = query[:-1]
selected = 0
scroll = 0
elif ch.isprintable():
query += ch
selected = 0
scroll = 0
except Exception:
_write(SHOW_CURSOR)
raise
finally:
registry_executor.shutdown(wait=False, cancel_futures=True)
def pick_args(tty_input: TTYInput, tool: dict) -> Optional[dict]:
"""Inline argument picker."""
args = tool['args']
values = {a['flag']: a['default'] for a in args}
selected = 0
editing = None
edit_buf = ""
last_drawn = 0
_write(HIDE_CURSOR)
try:
while True:
if last_drawn:
clear_dropdown(last_drawn)
lines = []
lines.append(f"{BOLD}{tool['name']}{RESET} arguments:")
for i, arg in enumerate(args):
flag = arg['flag']
val = edit_buf if editing == i else values[flag]
if i == selected:
if editing == i:
line = f"{CYAN}{flag}: {YELLOW}{val}{RESET}"
else:
line = f"{CYAN}{BOLD}{flag}:{RESET} {val or f'{DIM}(empty){RESET}'}"
else:
line = f" {flag}: {val or f'{DIM}(empty){RESET}'}"
if arg['desc'] and editing != i:
line += f" {DIM}# {arg['desc'][:30]}{RESET}"
lines.append(line)
if editing is None:
lines.append(f"{DIM}Tab:edit Enter:run Esc:back{RESET}")
_write('\n'.join(lines) + '\n')
last_drawn = len(lines)
ch = tty_input.getch()
if editing is not None:
if ch in ('\r', '\n'): # Save
values[args[editing]['flag']] = edit_buf
editing = None
edit_buf = ""
elif ch == '\x1b': # Cancel edit
editing = None
edit_buf = ""
elif ch == '\x7f' or ch == '\b':
edit_buf = edit_buf[:-1]
elif ch.isprintable():
edit_buf += ch
else:
if ch in ('\r', '\n'): # Enter - run
clear_dropdown(last_drawn)
return values
elif ch == '\t': # Tab - edit
editing = selected
edit_buf = values[args[selected]['flag']]
elif ch == '\x1b' or ch == '\x03': # Back
clear_dropdown(last_drawn)
return None
elif ch == 'UP':
selected = max(0, selected - 1)
elif ch == 'DOWN':
selected = min(len(args) - 1, selected + 1)
except Exception:
_write(SHOW_CURSOR)
raise
def main():
"""Entry point for cf command."""
global _ui_out
import argparse
import subprocess
import signal
# Parse metadata flags before touching stdin or /dev/tty. This keeps
# ``cf --help`` and ``cf --version`` usable in packaging checks, CI, and
# other non-interactive environments.
parser = argparse.ArgumentParser(
prog="cf",
description="Interactively find and run local or registry CmdForge tools",
)
from .. import __version__
parser.add_argument(
"--version", action="version", version=f"%(prog)s {__version__}"
)
parser.parse_args()
# Handle Ctrl+C gracefully
def handle_sigint(sig, frame):
# Restore cursor and exit cleanly
sys.stderr.write(SHOW_CURSOR)
sys.stderr.flush()
sys.exit(0)
signal.signal(signal.SIGINT, handle_sigint)
# Read piped input if stdin is not a tty
piped_input = None
if not sys.stdin.isatty():
try:
piped_input = sys.stdin.read()
except KeyboardInterrupt:
sys.exit(0)
# Check if we have a terminal available
if not os.path.exists('/dev/tty'):
print("cf requires a terminal", file=sys.stderr)
sys.exit(1)
# Use stderr for UI if stdout is piped, so tool output stays clean
_ui_out = sys.stderr if not sys.stdout.isatty() else sys.stdout
try:
from ..usage import get_suggestions
suggestions = get_suggestions()[:3]
if suggestions:
_write(f"{GREEN}Frequent pipelines you could save as tools:{RESET}\n")
for item in suggestions:
_write(
f" {' | '.join(item['tools'])} "
f"{DIM}({item['count']} uses){RESET}\n"
)
except (OSError, ValueError):
pass
try:
with TTYInput() as tty_input:
result = run_picker(tty_input)
except KeyboardInterrupt:
_write(SHOW_CURSOR)
sys.exit(0)
except OSError as e:
print(f"cf requires a terminal: {e}", file=sys.stderr)
sys.exit(1)
if result:
# Build command
cmd = [result.tool_name]
for flag, val in result.arguments.items():
if val:
cmd.extend([flag, val])
# Show what we're running and flush immediately
sys.stderr.write(f"{BOLD}{' '.join(cmd)}{RESET}\n")
sys.stderr.flush()
# Run it - use subprocess if we have piped input, exec otherwise
tool_path = os.path.expanduser(f"~/.local/bin/{result.tool_name}")
if piped_input is not None:
# Use subprocess so we can pass stdin
if os.path.exists(tool_path):
proc = subprocess.run([tool_path] + cmd[1:], input=piped_input, text=True)
else:
proc = subprocess.run(
["python", "-m", "cmdforge.runner"] + cmd,
input=piped_input, text=True
)
sys.exit(proc.returncode)
else:
# No piped input - use exec for efficiency
if os.path.exists(tool_path):
os.execv(tool_path, cmd)
else:
os.execlp("python", "python", "-m", "cmdforge.runner", *cmd)
if __name__ == "__main__":
main()