553 lines
18 KiB
Python
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()
|