"""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()