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 <noreply@anthropic.com>
This commit is contained in:
rob 2026-01-18 02:26:46 -04:00
parent 3d87d888ab
commit 7f0dabd328
2 changed files with 274 additions and 0 deletions

View File

@ -63,6 +63,7 @@ all = [
[project.scripts]
cmdforge = "cmdforge.cli:main"
cf = "cmdforge.cli.picker:main"
[project.urls]
Homepage = "https://cmdforge.brrd.tech"

273
src/cmdforge/cli/picker.py Normal file
View File

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