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 <noreply@anthropic.com>
This commit is contained in:
rob 2026-01-18 03:34:55 -04:00
parent 9f3227f7ef
commit f6bb863f60
2 changed files with 143 additions and 50 deletions

View File

@ -4,6 +4,7 @@ import sys
import os import os
import tty import tty
import termios import termios
import select
from typing import List, Tuple, Optional from typing import List, Tuple, Optional
from dataclasses import dataclass from dataclasses import dataclass
@ -31,6 +32,15 @@ RESET = "\033[0m"
MAX_VISIBLE = 8 # Show at most 8 items 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]: def fuzzy_match(query: str, text: str) -> Tuple[bool, int]:
"""Fuzzy match with scoring.""" """Fuzzy match with scoring."""
@ -74,40 +84,60 @@ def get_tools() -> List[dict]:
return sorted(tools, key=lambda t: t["name"]) return sorted(tools, key=lambda t: t["name"])
def getch(): class TTYInput:
"""Read a single character from stdin.""" """Read from /dev/tty for keyboard input, even when stdin is piped."""
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd) def __init__(self):
try: self.tty = None
tty.setraw(fd) self.fd = None
ch = sys.stdin.read(1) self.old_settings = None
# Handle escape sequences (arrow keys)
if ch == '\x1b': def __enter__(self):
ch2 = sys.stdin.read(1) self.tty = open('/dev/tty', 'r')
if ch2 == '[': self.fd = self.tty.fileno()
ch3 = sys.stdin.read(1) self.old_settings = termios.tcgetattr(self.fd)
if ch3 == 'A': return 'UP' return self
if ch3 == 'B': return 'DOWN'
if ch3 == 'C': return 'RIGHT' def __exit__(self, *args):
if ch3 == 'D': return 'LEFT' if self.old_settings:
return ch termios.tcsetattr(self.fd, termios.TCSANOW, self.old_settings)
finally: if self.tty:
termios.tcsetattr(fd, termios.TCSADRAIN, old) 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): def clear_dropdown(n_lines: int):
"""Clear the dropdown lines we drew.""" """Clear the dropdown lines we drew."""
for _ in range(n_lines): for _ in range(n_lines):
sys.stdout.write(MOVE_UP + CLEAR_LINE) _write(MOVE_UP + CLEAR_LINE)
sys.stdout.write('\r') _write('\r')
sys.stdout.flush()
def run_picker() -> Optional[PickerResult]: def run_picker(tty_input: TTYInput) -> Optional[PickerResult]:
"""Run inline picker.""" """Run inline picker."""
tools = get_tools() tools = get_tools()
if not tools: if not tools:
print("No tools. Create one: cmdforge") _write("No tools. Create one: cmdforge\n")
return None return None
query = "" query = ""
@ -115,7 +145,7 @@ def run_picker() -> Optional[PickerResult]:
scroll = 0 scroll = 0
last_drawn = 0 last_drawn = 0
print(HIDE_CURSOR, end='', flush=True) _write(HIDE_CURSOR)
try: try:
while True: while True:
@ -171,33 +201,32 @@ def run_picker() -> Optional[PickerResult]:
lines.append(prefix) lines.append(prefix)
# Print # Print
sys.stdout.write('\n'.join(lines) + '\n') _write('\n'.join(lines) + '\n')
sys.stdout.flush()
last_drawn = len(lines) last_drawn = len(lines)
# Input # Input
ch = getch() ch = tty_input.getch()
if ch in ('\r', '\n'): # Enter - run if ch in ('\r', '\n'): # Enter - run
if filtered: if filtered:
clear_dropdown(last_drawn) clear_dropdown(last_drawn)
print(SHOW_CURSOR, end='', flush=True) _write(SHOW_CURSOR)
return PickerResult(filtered[selected]["name"], {}) return PickerResult(filtered[selected]["name"], {})
elif ch == '\t': # Tab - args or run elif ch == '\t': # Tab - configure args or run
if filtered: if filtered:
tool = filtered[selected] tool = filtered[selected]
clear_dropdown(last_drawn) clear_dropdown(last_drawn)
if tool['args']: if tool['args']:
args = pick_args(tool) args = pick_args(tty_input, tool)
print(SHOW_CURSOR, end='', flush=True) _write(SHOW_CURSOR)
return PickerResult(tool["name"], args) if args is not None else None 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"], {}) return PickerResult(tool["name"], {})
elif ch == '\x1b' or ch == '\x03': # Esc or Ctrl+C elif ch == '\x1b' or ch == '\x03': # Esc or Ctrl+C
clear_dropdown(last_drawn) clear_dropdown(last_drawn)
print(SHOW_CURSOR, end='', flush=True) _write(SHOW_CURSOR)
return None return None
elif ch == 'UP': elif ch == 'UP':
@ -215,12 +244,12 @@ def run_picker() -> Optional[PickerResult]:
selected = 0 selected = 0
scroll = 0 scroll = 0
except Exception as e: except Exception:
print(SHOW_CURSOR, end='', flush=True) _write(SHOW_CURSOR)
raise raise
def pick_args(tool: dict) -> Optional[dict]: def pick_args(tty_input: TTYInput, tool: dict) -> Optional[dict]:
"""Inline argument picker.""" """Inline argument picker."""
args = tool['args'] args = tool['args']
values = {a['flag']: a['default'] for a in args} values = {a['flag']: a['default'] for a in args}
@ -229,7 +258,7 @@ def pick_args(tool: dict) -> Optional[dict]:
edit_buf = "" edit_buf = ""
last_drawn = 0 last_drawn = 0
print(HIDE_CURSOR, end='', flush=True) _write(HIDE_CURSOR)
try: try:
while True: while True:
@ -259,11 +288,10 @@ def pick_args(tool: dict) -> Optional[dict]:
if editing is None: if editing is None:
lines.append(f"{DIM}Tab:edit Enter:run Esc:back{RESET}") lines.append(f"{DIM}Tab:edit Enter:run Esc:back{RESET}")
sys.stdout.write('\n'.join(lines) + '\n') _write('\n'.join(lines) + '\n')
sys.stdout.flush()
last_drawn = len(lines) last_drawn = len(lines)
ch = getch() ch = tty_input.getch()
if editing is not None: if editing is not None:
if ch in ('\r', '\n'): # Save if ch in ('\r', '\n'): # Save
@ -293,17 +321,34 @@ def pick_args(tool: dict) -> Optional[dict]:
selected = min(len(args) - 1, selected + 1) selected = min(len(args) - 1, selected + 1)
except Exception: except Exception:
print(SHOW_CURSOR, end='', flush=True) _write(SHOW_CURSOR)
raise raise
def main(): def main():
"""Entry point for cf command.""" """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(): 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) print("cf requires a terminal", file=sys.stderr)
sys.exit(1) 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: if result:
# Build command # Build command
@ -312,15 +357,29 @@ def main():
if val: if val:
cmd.extend([flag, val]) cmd.extend([flag, val])
# Show what we're running # Show what we're running and flush immediately
print(f"{BOLD}{' '.join(cmd)}{RESET}", file=sys.stderr) 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}") 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: 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__": if __name__ == "__main__":

View File

@ -35,7 +35,10 @@ cmdforge create # CLI wizard
cmdforge registry install official/summarize cmdforge registry install official/summarize
# Use it! # Use it!
cat article.txt | summarize</code></pre> cat article.txt | summarize
# Or use the interactive picker to browse and run tools
cf</code></pre>
<div class="bg-cyan-50 border-l-4 border-cyan-500 p-4 my-4"> <div class="bg-cyan-50 border-l-4 border-cyan-500 p-4 my-4">
<p class="font-semibold text-cyan-800">Two Ways to Build</p> <p class="font-semibold text-cyan-800">Two Ways to Build</p>
@ -3949,6 +3952,36 @@ cat file.txt | summarize --provider mock</code></pre>
</tbody> </tbody>
</table> </table>
<h2 id="interactive-picker">Interactive Tool Picker (cf)</h2>
<p>The <code>cf</code> 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.</p>
<pre><code class="language-bash"># Launch the picker
cf
# Pipe input through the picker
cat document.txt | cf
# Chain pickers for interactive pipelines
cf | cf | cf</code></pre>
<p><strong>Controls:</strong></p>
<ul>
<li><strong>Type</strong> - Fuzzy filter tools by name or description</li>
<li><strong>/</strong> - Navigate the list</li>
<li><strong>Enter</strong> - Run the selected tool</li>
<li><strong>Tab</strong> - Configure arguments before running (if tool has arguments)</li>
<li><strong>Esc</strong> - Cancel</li>
</ul>
<div class="bg-cyan-50 border-l-4 border-cyan-500 p-4 my-4">
<p class="font-semibold text-cyan-800">Interactive Pipelines</p>
<p class="text-cyan-700">Chain <code>cf</code> commands to build pipelines on the fly:
<code>cat data.txt | cf | cf</code> 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.</p>
</div>
<h2 id="registry-commands">Registry Commands</h2> <h2 id="registry-commands">Registry Commands</h2>
<table class="w-full my-4"> <table class="w-full my-4">
@ -4166,6 +4199,7 @@ cat file.txt | summarize --provider mock</code></pre>
""", """,
"headings": [ "headings": [
("tool-commands", "Tool Commands"), ("tool-commands", "Tool Commands"),
("interactive-picker", "Interactive Tool Picker (cf)"),
("registry-commands", "Registry Commands"), ("registry-commands", "Registry Commands"),
("collection-commands", "Collection Commands"), ("collection-commands", "Collection Commands"),
("provider-commands", "Provider Commands"), ("provider-commands", "Provider Commands"),