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:
parent
9f3227f7ef
commit
f6bb863f60
|
|
@ -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__":
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue