From f6bb863f60e4a557a1f29b81f929ddb2f5bdb981 Mon Sep 17 00:00:00 2001 From: rob Date: Sun, 18 Jan 2026 03:34:55 -0400 Subject: [PATCH] Add piped input support and documentation for cf picker - Support piped input: cat file.txt | cf | cf - Write picker UI to stderr when stdout is piped - Use /dev/tty for keyboard input independent of stdin - Add select() for non-blocking escape sequence detection - Document cf in CLI reference and Getting Started Co-Authored-By: Claude Opus 4.5 --- src/cmdforge/cli/picker.py | 157 +++++++++++++++++++++---------- src/cmdforge/web/docs_content.py | 36 ++++++- 2 files changed, 143 insertions(+), 50 deletions(-) diff --git a/src/cmdforge/cli/picker.py b/src/cmdforge/cli/picker.py index f83cd63..5ec4182 100644 --- a/src/cmdforge/cli/picker.py +++ b/src/cmdforge/cli/picker.py @@ -4,6 +4,7 @@ import sys import os import tty import termios +import select from typing import List, Tuple, Optional from dataclasses import dataclass @@ -31,6 +32,15 @@ RESET = "\033[0m" MAX_VISIBLE = 8 # Show at most 8 items +# 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.""" @@ -74,40 +84,60 @@ def get_tools() -> List[dict]: return sorted(tools, key=lambda t: t["name"]) -def getch(): - """Read a single character from stdin.""" - fd = sys.stdin.fileno() - old = termios.tcgetattr(fd) - try: - tty.setraw(fd) - ch = sys.stdin.read(1) - # Handle escape sequences (arrow keys) - if ch == '\x1b': - ch2 = sys.stdin.read(1) - if ch2 == '[': - ch3 = sys.stdin.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(fd, termios.TCSADRAIN, old) +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): + """Read a single character.""" + tty.setraw(self.fd) + try: + ch = self.tty.read(1) + if ch == '\x1b': + # Use select to check if more chars available (escape sequence) + # Timeout of 0.05s - if nothing comes, it was just Escape + if select.select([self.fd], [], [], 0.05)[0]: + ch2 = self.tty.read(1) + if ch2 == '[': + if select.select([self.fd], [], [], 0.05)[0]: + 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): - sys.stdout.write(MOVE_UP + CLEAR_LINE) - sys.stdout.write('\r') - sys.stdout.flush() + _write(MOVE_UP + CLEAR_LINE) + _write('\r') -def run_picker() -> Optional[PickerResult]: +def run_picker(tty_input: TTYInput) -> Optional[PickerResult]: """Run inline picker.""" tools = get_tools() if not tools: - print("No tools. Create one: cmdforge") + _write("No tools. Create one: cmdforge\n") return None query = "" @@ -115,7 +145,7 @@ def run_picker() -> Optional[PickerResult]: scroll = 0 last_drawn = 0 - print(HIDE_CURSOR, end='', flush=True) + _write(HIDE_CURSOR) try: while True: @@ -171,33 +201,32 @@ def run_picker() -> Optional[PickerResult]: lines.append(prefix) # Print - sys.stdout.write('\n'.join(lines) + '\n') - sys.stdout.flush() + _write('\n'.join(lines) + '\n') last_drawn = len(lines) # Input - ch = getch() + ch = tty_input.getch() if ch in ('\r', '\n'): # Enter - run if filtered: clear_dropdown(last_drawn) - print(SHOW_CURSOR, end='', flush=True) + _write(SHOW_CURSOR) return PickerResult(filtered[selected]["name"], {}) - elif ch == '\t': # Tab - args or run + elif ch == '\t': # Tab - configure args or run if filtered: tool = filtered[selected] clear_dropdown(last_drawn) if tool['args']: - args = pick_args(tool) - print(SHOW_CURSOR, end='', flush=True) + args = pick_args(tty_input, tool) + _write(SHOW_CURSOR) return PickerResult(tool["name"], args) if args is not None else None - print(SHOW_CURSOR, end='', flush=True) + _write(SHOW_CURSOR) return PickerResult(tool["name"], {}) elif ch == '\x1b' or ch == '\x03': # Esc or Ctrl+C clear_dropdown(last_drawn) - print(SHOW_CURSOR, end='', flush=True) + _write(SHOW_CURSOR) return None elif ch == 'UP': @@ -215,12 +244,12 @@ def run_picker() -> Optional[PickerResult]: selected = 0 scroll = 0 - except Exception as e: - print(SHOW_CURSOR, end='', flush=True) + except Exception: + _write(SHOW_CURSOR) raise -def pick_args(tool: dict) -> Optional[dict]: +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} @@ -229,7 +258,7 @@ def pick_args(tool: dict) -> Optional[dict]: edit_buf = "" last_drawn = 0 - print(HIDE_CURSOR, end='', flush=True) + _write(HIDE_CURSOR) try: while True: @@ -259,11 +288,10 @@ def pick_args(tool: dict) -> Optional[dict]: if editing is None: lines.append(f"{DIM}Tab:edit Enter:run Esc:back{RESET}") - sys.stdout.write('\n'.join(lines) + '\n') - sys.stdout.flush() + _write('\n'.join(lines) + '\n') last_drawn = len(lines) - ch = getch() + ch = tty_input.getch() if editing is not None: if ch in ('\r', '\n'): # Save @@ -293,17 +321,34 @@ def pick_args(tool: dict) -> Optional[dict]: selected = min(len(args) - 1, selected + 1) except Exception: - print(SHOW_CURSOR, end='', flush=True) + _write(SHOW_CURSOR) raise def main(): """Entry point for cf command.""" + global _ui_out + import subprocess + + # Read piped input if stdin is not a tty + piped_input = None if not sys.stdin.isatty(): + piped_input = sys.stdin.read() + + # 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) - result = run_picker() + # 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: + with TTYInput() as tty_input: + result = run_picker(tty_input) + except OSError as e: + print(f"cf requires a terminal: {e}", file=sys.stderr) + sys.exit(1) if result: # Build command @@ -312,15 +357,29 @@ def main(): if val: cmd.extend([flag, val]) - # Show what we're running - print(f"{BOLD}{' '.join(cmd)}{RESET}", file=sys.stderr) + # Show what we're running and flush immediately + sys.stderr.write(f"{BOLD}{' '.join(cmd)}{RESET}\n") + sys.stderr.flush() - # Run it + # Run it - use subprocess if we have piped input, exec otherwise tool_path = os.path.expanduser(f"~/.local/bin/{result.tool_name}") - if os.path.exists(tool_path): - os.execv(tool_path, cmd) + + 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: - os.execlp("python", "python", "-m", "cmdforge.runner", *cmd) + # 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__": diff --git a/src/cmdforge/web/docs_content.py b/src/cmdforge/web/docs_content.py index 9c933d4..0386ea0 100644 --- a/src/cmdforge/web/docs_content.py +++ b/src/cmdforge/web/docs_content.py @@ -35,7 +35,10 @@ cmdforge create # CLI wizard cmdforge registry install official/summarize # Use it! -cat article.txt | summarize +cat article.txt | summarize + +# Or use the interactive picker to browse and run tools +cf

Two Ways to Build

@@ -3949,6 +3952,36 @@ cat file.txt | summarize --provider mock +

Interactive Tool Picker (cf)

+ +

The cf command provides a fast, fuzzy-search picker for running tools interactively. +Instead of remembering exact tool names, just type a few characters to filter and select.

+ +
# Launch the picker
+cf
+
+# Pipe input through the picker
+cat document.txt | cf
+
+# Chain pickers for interactive pipelines
+cf | cf | cf
+ +

Controls:

+
    +
  • Type - Fuzzy filter tools by name or description
  • +
  • ↑/↓ - Navigate the list
  • +
  • Enter - Run the selected tool
  • +
  • Tab - Configure arguments before running (if tool has arguments)
  • +
  • Esc - Cancel
  • +
+ +
+

Interactive Pipelines

+

Chain cf commands to build pipelines on the fly: + cat data.txt | cf | cf lets you pick a tool, then pick another tool to process its output. + The picker UI is shown on stderr so it doesn't interfere with piped data.

+
+

Registry Commands

@@ -4166,6 +4199,7 @@ cat file.txt | summarize --provider mock """, "headings": [ ("tool-commands", "Tool Commands"), + ("interactive-picker", "Interactive Tool Picker (cf)"), ("registry-commands", "Registry Commands"), ("collection-commands", "Collection Commands"), ("provider-commands", "Provider Commands"),