Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,7 @@ This is `reed`, a Python CLI wrapper around piper-tts for text-to-speech on macO
- Commands available in interactive mode: `/quit`, `/exit`, `/help`, `/clear`, `/replay`
- Tab autocomplete available for commands
- Include `print_fn` parameter for testability with dependency injection

## Git

- Do NOT add `Co-authored-by` or `Amp-Thread-ID` trailers to commits
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## Unreleased

### Platform abstraction
- **macOS and Linux support**: Audio playback and clipboard commands are now auto-detected at runtime instead of being hardcoded to macOS tools.
- Audio: `afplay` (macOS), `paplay`/`aplay`/`ffplay` (Linux)
- Clipboard: `pbpaste` (macOS), `wl-paste`/`xclip`/`xsel` (Linux)
- **`get_text()` now uses injected `run`**: The clipboard path previously called `subprocess.run` directly, bypassing the dependency injection pattern used everywhere else. Now consistent.

### Rich terminal UI
- **Added Rich library** for styled terminal output (colors, panels, spinners).
- **Interactive banner** with styled markup replaces plain-text banner lines.
Expand Down Expand Up @@ -34,6 +40,6 @@
- **Dependencies declared**: `piper-tts`, `prompt-toolkit`, and `rich` listed in `pyproject.toml`; `mypy` and `pytest` as optional dev dependencies.

### Tests
- **32 tests** (up from 25): added coverage for `/replay` (with and without prior text), `/clear` (verifies `clear_fn` called), afplay failure, missing model error, empty text error, `ReedError` propagation through `main`, `_should_enter_interactive` with `None` stdin, and `get_text` with text args.
- **44 tests** (up from 25): added coverage for platform detection (`_default_play_cmd`, `_default_clipboard_cmd`), clipboard `run` injection, `/replay` (with and without prior text), `/clear` (verifies `clear_fn` called), afplay failure, missing model error, empty text error, `ReedError` propagation through `main`, `_should_enter_interactive` with `None` stdin, and `get_text` with text args.
- Banner test now captures `print_fn` output and verifies the banner was actually printed.
- `TestMainErrors` uses Rich `Console` capture for end-to-end error message verification.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ A CLI that reads text aloud using [piper-tts](https://github.com/rhasspy/piper).
## Requirements

- Python 3.14+
- macOS (uses `afplay` for audio playback and `pbpaste` for clipboard)
- macOS or Linux
- **macOS**: `afplay` (audio), `pbpaste` (clipboard) — included with the OS
- **Linux**: one of `paplay`, `aplay`, or `ffplay` (audio); one of `wl-paste`, `xclip`, or `xsel` (clipboard)
- [uv](https://docs.astral.sh/uv/) (for dependency management)
- Rich library for beautiful terminal UI

Expand Down
46 changes: 41 additions & 5 deletions reed.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
"""reed - A CLI that reads text aloud using piper-tts."""

import argparse
import platform
import shutil
import subprocess
import sys
import tempfile
Expand Down Expand Up @@ -54,9 +56,42 @@ class ReedConfig:
}


def get_text(args: argparse.Namespace, stdin: TextIO) -> str:
def _default_play_cmd() -> list[str]:
if platform.system() == "Darwin":
return ["afplay"]
if platform.system() == "Linux":
for cmd, args in [
("paplay", []),
("aplay", []),
("ffplay", ["-nodisp", "-autoexit"]),
]:
if shutil.which(cmd):
return [cmd, *args]
raise ReedError("No supported audio player found")


def _default_clipboard_cmd() -> list[str]:
if platform.system() == "Darwin":
return ["pbpaste"]
if platform.system() == "Linux":
for cmd, args in [
("wl-paste", []),
("xclip", ["-selection", "clipboard", "-o"]),
("xsel", ["--clipboard", "--output"]),
]:
if shutil.which(cmd):
return [cmd, *args]
raise ReedError("No supported clipboard tool found")


def get_text(
args: argparse.Namespace,
stdin: TextIO,
run: Callable = subprocess.run,
) -> str:
if args.clipboard:
result = subprocess.run(["pbpaste"], capture_output=True, text=True)
clipboard_cmd = _default_clipboard_cmd()
result = run(clipboard_cmd, capture_output=True, text=True)
if result.returncode != 0:
raise ReedError("Failed to read clipboard")
return result.stdout.strip()
Expand Down Expand Up @@ -175,9 +210,10 @@ def speak_text(
f"\n[bold green]✓ Generated in {time.time() - start:.1f}s[/bold green]"
)
print_playback_progress(print_fn)
result = run(["afplay", tmp.name])
play_cmd = _default_play_cmd()
result = run([*play_cmd, tmp.name])
if result.returncode != 0:
raise ReedError("afplay error")
raise ReedError("playback error")
print_fn("[bold green]✓ Done[/bold green]")


Expand Down Expand Up @@ -340,7 +376,7 @@ def main(

try:
assert stdin is not None
text = get_text(args, stdin)
text = get_text(args, stdin, run=run)

if not text:
print_error("No text to read.", print_fn)
Expand Down
161 changes: 154 additions & 7 deletions test_reed.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,8 +262,8 @@ def test_with_output_file(self):


class TestSpeakText:
def test_play_path_calls_piper_then_afplay(self):
from reed import speak_text
def test_play_path_calls_piper_then_player(self):
from reed import speak_text, _default_play_cmd

calls = []

Expand All @@ -277,7 +277,8 @@ def fake_run(cmd, **kwargs):
assert len(calls) == 2
assert calls[0][0][1:3] == ["-m", "piper"]
assert calls[0][1].get("input") == "hi"
assert calls[1][0][0] == "afplay"
play_cmd = _default_play_cmd()
assert calls[1][0][: len(play_cmd)] == play_cmd

def test_output_path_no_afplay(self):
from reed import speak_text
Expand Down Expand Up @@ -306,7 +307,7 @@ def fake_run(cmd, **kwargs):
with pytest.raises(ReedError, match="boom"):
speak_text("hi", config, run=fake_run)

def test_afplay_error_raises(self):
def test_playback_error_raises(self):
from reed import speak_text, ReedError

call_count = 0
Expand All @@ -318,9 +319,9 @@ def fake_run(cmd, **kwargs):
return types.SimpleNamespace(returncode=0, stderr="")
return types.SimpleNamespace(returncode=1, stderr="")

config = _make_config()
with pytest.raises(ReedError, match="afplay error"):
speak_text("hi", config, run=fake_run)
args = _make_args()
with pytest.raises(ReedError, match="playback error"):
speak_text("hi", args, run=fake_run)


# ─── main integration tests ──────────────────────────────────────────
Expand Down Expand Up @@ -401,6 +402,152 @@ def test_none_stdin(self):
assert _should_enter_interactive(args, None) is False


# ─── _default_play_cmd tests ──────────────────────────────────────────


class TestDefaultPlayCmd:
def test_macos_returns_afplay(self, monkeypatch):
from reed import _default_play_cmd

monkeypatch.setattr("reed.platform.system", lambda: "Darwin")
assert _default_play_cmd() == ["afplay"]

def test_linux_paplay(self, monkeypatch):
from reed import _default_play_cmd

monkeypatch.setattr("reed.platform.system", lambda: "Linux")
monkeypatch.setattr(
"reed.shutil.which",
lambda cmd: "/usr/bin/paplay" if cmd == "paplay" else None,
)
assert _default_play_cmd() == ["paplay"]

def test_linux_aplay_fallback(self, monkeypatch):
from reed import _default_play_cmd

monkeypatch.setattr("reed.platform.system", lambda: "Linux")
monkeypatch.setattr(
"reed.shutil.which",
lambda cmd: "/usr/bin/aplay" if cmd == "aplay" else None,
)
assert _default_play_cmd() == ["aplay"]

def test_linux_ffplay_fallback(self, monkeypatch):
from reed import _default_play_cmd

monkeypatch.setattr("reed.platform.system", lambda: "Linux")
monkeypatch.setattr(
"reed.shutil.which",
lambda cmd: "/usr/bin/ffplay" if cmd == "ffplay" else None,
)
assert _default_play_cmd() == ["ffplay", "-nodisp", "-autoexit"]

def test_linux_no_player_raises(self, monkeypatch):
from reed import _default_play_cmd, ReedError

monkeypatch.setattr("reed.platform.system", lambda: "Linux")
monkeypatch.setattr("reed.shutil.which", lambda cmd: None)
with pytest.raises(ReedError, match="No supported audio player found"):
_default_play_cmd()

def test_unknown_platform_raises(self, monkeypatch):
from reed import _default_play_cmd, ReedError

monkeypatch.setattr("reed.platform.system", lambda: "Windows")
with pytest.raises(ReedError, match="No supported audio player found"):
_default_play_cmd()


# ─── _default_clipboard_cmd tests ────────────────────────────────────


class TestDefaultClipboardCmd:
def test_macos_returns_pbpaste(self, monkeypatch):
from reed import _default_clipboard_cmd

monkeypatch.setattr("reed.platform.system", lambda: "Darwin")
assert _default_clipboard_cmd() == ["pbpaste"]

def test_linux_wl_paste(self, monkeypatch):
from reed import _default_clipboard_cmd

monkeypatch.setattr("reed.platform.system", lambda: "Linux")
monkeypatch.setattr(
"reed.shutil.which",
lambda cmd: "/usr/bin/wl-paste" if cmd == "wl-paste" else None,
)
assert _default_clipboard_cmd() == ["wl-paste"]

def test_linux_xclip_fallback(self, monkeypatch):
from reed import _default_clipboard_cmd

monkeypatch.setattr("reed.platform.system", lambda: "Linux")
monkeypatch.setattr(
"reed.shutil.which",
lambda cmd: "/usr/bin/xclip" if cmd == "xclip" else None,
)
assert _default_clipboard_cmd() == ["xclip", "-selection", "clipboard", "-o"]

def test_linux_xsel_fallback(self, monkeypatch):
from reed import _default_clipboard_cmd

monkeypatch.setattr("reed.platform.system", lambda: "Linux")
monkeypatch.setattr(
"reed.shutil.which", lambda cmd: "/usr/bin/xsel" if cmd == "xsel" else None
)
assert _default_clipboard_cmd() == ["xsel", "--clipboard", "--output"]

def test_linux_no_clipboard_raises(self, monkeypatch):
from reed import _default_clipboard_cmd, ReedError

monkeypatch.setattr("reed.platform.system", lambda: "Linux")
monkeypatch.setattr("reed.shutil.which", lambda cmd: None)
with pytest.raises(ReedError, match="No supported clipboard tool found"):
_default_clipboard_cmd()

def test_unknown_platform_raises(self, monkeypatch):
from reed import _default_clipboard_cmd, ReedError

monkeypatch.setattr("reed.platform.system", lambda: "Windows")
with pytest.raises(ReedError, match="No supported clipboard tool found"):
_default_clipboard_cmd()


# ─── get_text clipboard with run injection test ──────────────────────


class TestGetTextClipboard:
def test_clipboard_uses_injected_run(self):
from reed import get_text

def fake_run(cmd, **kwargs):
return types.SimpleNamespace(
returncode=0, stdout="clipboard text", stderr=""
)

class FakeTty:
def isatty(self):
return True

args = _make_args(clipboard=True)
result = get_text(args, stdin=FakeTty(), run=fake_run)
assert result == "clipboard text"

def test_clipboard_error_raises(self):
from reed import get_text, ReedError

def fake_run(cmd, **kwargs):
return types.SimpleNamespace(returncode=1, stdout="", stderr="fail")

class FakeTty:
def isatty(self):
return True

args = _make_args(clipboard=True)
with pytest.raises(ReedError, match="Failed to read clipboard"):
get_text(args, stdin=FakeTty(), run=fake_run)


# ─── get_text with stdin injection tests ─────────────────────────────


Expand Down
Loading