From 7f0dabd328166e38b7ca8ef51124af280315fc3c Mon Sep 17 00:00:00 2001 From: rob Date: Sun, 18 Jan 2026 02:26:46 -0400 Subject: [PATCH] Add interactive fuzzy tool picker (cf command) New `cf` command provides a fuzzy-searchable tool picker: - Type to filter tools by name or description - Arrow keys to navigate - Enter/Tab to select and run - ? to show arguments for selected tool - Escape to cancel Uses curses for a lightweight terminal UI with no extra dependencies. Co-Authored-By: Claude Opus 4.5 --- pyproject.toml | 1 + src/cmdforge/cli/picker.py | 273 +++++++++++++++++++++++++++++++++++++ 2 files changed, 274 insertions(+) create mode 100644 src/cmdforge/cli/picker.py diff --git a/pyproject.toml b/pyproject.toml index 0159613..7b2f879 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,7 @@ all = [ [project.scripts] cmdforge = "cmdforge.cli:main" +cf = "cmdforge.cli.picker:main" [project.urls] Homepage = "https://cmdforge.brrd.tech" diff --git a/src/cmdforge/cli/picker.py b/src/cmdforge/cli/picker.py new file mode 100644 index 0000000..8279b09 --- /dev/null +++ b/src/cmdforge/cli/picker.py @@ -0,0 +1,273 @@ +"""Interactive fuzzy tool picker for CmdForge.""" + +import curses +import sys +import os +import subprocess +from typing import List, Tuple, Optional + +from ..tool import list_tools, load_tool + + +def fuzzy_match(query: str, text: str) -> Tuple[bool, int]: + """ + Check if query fuzzy-matches text. + Returns (matches, score) where higher score = better match. + """ + if not query: + return True, 0 + + query = query.lower() + text = text.lower() + + # Exact substring match gets highest score + if query in text: + # Bonus for matching at start + if text.startswith(query): + return True, 1000 + len(query) + return True, 500 + len(query) + + # Fuzzy match - all query chars must appear in order + query_idx = 0 + score = 0 + prev_match_idx = -1 + + for i, char in enumerate(text): + if query_idx < len(query) and char == query[query_idx]: + # Bonus for consecutive matches + if prev_match_idx == i - 1: + score += 10 + else: + score += 1 + prev_match_idx = i + query_idx += 1 + + if query_idx == len(query): + return True, score + + return False, 0 + + +def get_tools_with_info() -> List[dict]: + """Get all tools with their info for display.""" + tools = [] + for name in list_tools(): + tool = load_tool(name) + if tool: + args = [arg.flag for arg in tool.arguments] if tool.arguments else [] + tools.append({ + "name": name, + "description": tool.description or "", + "category": tool.category or "Other", + "arguments": args, + }) + return sorted(tools, key=lambda t: t["name"]) + + +def run_picker() -> Optional[str]: + """ + Run the interactive fuzzy picker. + Returns the selected tool name, or None if cancelled. + """ + tools = get_tools_with_info() + + if not tools: + print("No tools found. Create one with: cmdforge") + return None + + return curses.wrapper(lambda stdscr: _picker_loop(stdscr, tools)) + + +def _picker_loop(stdscr, tools: List[dict]) -> Optional[str]: + """Main picker loop using curses.""" + curses.curs_set(1) # Show cursor + curses.use_default_colors() + + # Initialize colors + curses.init_pair(1, curses.COLOR_CYAN, -1) # Selected item + curses.init_pair(2, curses.COLOR_YELLOW, -1) # Query + curses.init_pair(3, curses.COLOR_GREEN, -1) # Match highlight + curses.init_pair(4, 8, -1) # Dim (gray) - description + + query = "" + selected_idx = 0 + scroll_offset = 0 + + while True: + stdscr.clear() + height, width = stdscr.getmaxyx() + + # Filter tools based on query + filtered = [] + for tool in tools: + # Match against name and description + match_name, score_name = fuzzy_match(query, tool["name"]) + match_desc, score_desc = fuzzy_match(query, tool["description"]) + + if match_name or match_desc: + score = max(score_name * 2, score_desc) # Prioritize name matches + filtered.append((tool, score)) + + # Sort by score (highest first) + filtered.sort(key=lambda x: x[1], reverse=True) + filtered_tools = [t[0] for t in filtered] + + # Adjust selection if out of bounds + if selected_idx >= len(filtered_tools): + selected_idx = max(0, len(filtered_tools) - 1) + + # Calculate visible area (leave room for header and footer) + list_height = height - 4 + + # Adjust scroll offset + if selected_idx < scroll_offset: + scroll_offset = selected_idx + elif selected_idx >= scroll_offset + list_height: + scroll_offset = selected_idx - list_height + 1 + + # Draw header + header = f" CmdForge Tools ({len(filtered_tools)}/{len(tools)}) " + stdscr.attron(curses.A_BOLD) + stdscr.addstr(0, 0, header[:width-1]) + stdscr.attroff(curses.A_BOLD) + + # Draw query line + prompt = "> " + stdscr.addstr(1, 0, prompt) + stdscr.attron(curses.color_pair(2)) + stdscr.addstr(1, len(prompt), query[:width-len(prompt)-1]) + stdscr.attroff(curses.color_pair(2)) + + # Draw separator + stdscr.addstr(2, 0, "─" * min(width-1, 60)) + + # Draw tool list + visible_tools = filtered_tools[scroll_offset:scroll_offset + list_height] + + for i, tool in enumerate(visible_tools): + y = 3 + i + if y >= height - 1: + break + + actual_idx = scroll_offset + i + is_selected = actual_idx == selected_idx + + # Build display line + name = tool["name"] + desc = tool["description"][:width-len(name)-5] if tool["description"] else "" + + # Truncate if needed + max_name_len = min(30, width // 3) + if len(name) > max_name_len: + name = name[:max_name_len-1] + "…" + + if is_selected: + stdscr.attron(curses.color_pair(1) | curses.A_BOLD) + prefix = "▸ " + else: + prefix = " " + + try: + stdscr.addstr(y, 0, prefix) + stdscr.addstr(y, 2, name) + + if is_selected: + stdscr.attroff(curses.color_pair(1) | curses.A_BOLD) + + # Add description in gray + if desc: + desc_x = 2 + len(name) + 2 + if desc_x < width - 5: + stdscr.attron(curses.color_pair(4)) + stdscr.addstr(y, desc_x, f"- {desc}"[:width-desc_x-1]) + stdscr.attroff(curses.color_pair(4)) + except curses.error: + pass # Ignore drawing errors at screen edge + + # Draw footer with help + footer = " ↑↓:navigate Tab/Enter:select Esc:cancel ?:args " + try: + stdscr.attron(curses.A_DIM) + stdscr.addstr(height-1, 0, footer[:width-1]) + stdscr.attroff(curses.A_DIM) + except curses.error: + pass + + # Position cursor at end of query + try: + stdscr.move(1, len(prompt) + len(query)) + except curses.error: + pass + + stdscr.refresh() + + # Handle input + try: + key = stdscr.getch() + except KeyboardInterrupt: + return None + + if key == 27: # Escape + return None + elif key in (curses.KEY_ENTER, 10, 13, ord('\t')): # Enter or Tab + if filtered_tools: + return filtered_tools[selected_idx]["name"] + elif key == curses.KEY_UP: + selected_idx = max(0, selected_idx - 1) + elif key == curses.KEY_DOWN: + selected_idx = min(len(filtered_tools) - 1, selected_idx + 1) + elif key == curses.KEY_BACKSPACE or key == 127: + query = query[:-1] + selected_idx = 0 + scroll_offset = 0 + elif key == ord('?') and filtered_tools: + # Show arguments for selected tool + tool = filtered_tools[selected_idx] + if tool["arguments"]: + args_str = " ".join(tool["arguments"]) + stdscr.addstr(height-1, 0, f" Args: {args_str}"[:width-1] + " " * 20) + stdscr.refresh() + stdscr.getch() # Wait for any key + elif 32 <= key <= 126: # Printable characters + query += chr(key) + selected_idx = 0 + scroll_offset = 0 + + +def main(): + """Main entry point for the cf command.""" + # Check if we're in a terminal + if not sys.stdin.isatty(): + print("Error: cf requires an interactive terminal", file=sys.stderr) + sys.exit(1) + + try: + selected = run_picker() + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + if selected: + # Print the command so user can see what was selected + print(f"\033[1m{selected}\033[0m", file=sys.stderr) + + # Check if stdin has data piped (it shouldn't since we're interactive) + # Run the tool - it will read from stdin/user input + try: + # Use exec to replace this process with the tool + # This way the tool can properly interact with the terminal + tool_path = os.path.expanduser(f"~/.local/bin/{selected}") + if os.path.exists(tool_path): + os.execv(tool_path, [selected]) + else: + # Fall back to running via cmdforge runner + os.execlp("python", "python", "-m", "cmdforge.runner", selected) + except Exception as e: + # Fallback: just print the command + print(f"Run: {selected}") + else: + sys.exit(0) + + +if __name__ == "__main__": + main()