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
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,20 @@ Offline TTS using [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) via [k
From PyPI — recommended for most users:

```bash
pipx install stackvox # global CLI (`stackvox` and `stackvox-say` on PATH)
pipx install stackvox # `stackvox` CLI on PATH
# or
pip install stackvox # use as a library
pip install stackvox # use as a library
```

If you want the low-latency bash helper (`stackvox-say`) for shell scripts and hooks, install it on PATH after installing the package:

```bash
stackvox install-helper # copies bash helper to ~/.local/bin
# use --prefix DIR to install elsewhere
```

This is a one-time step. The helper is shipped as package data rather than as an automatic install script — explicit beats magical, and it keeps stackvox compatible with modern build backends. Skip it if you only ever use the Python `stackvox say` client.

From git, if you want an unreleased commit:

```bash
Expand Down
11 changes: 6 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,16 @@ Issues = "https://github.com/StackOneHQ/stackvox/issues"
[project.scripts]
stackvox = "stackvox.cli:main"

[tool.setuptools]
script-files = ["bin/stackvox-say"]

[tool.setuptools.packages.find]
include = ["stackvox*"]

[tool.setuptools.package-data]
# Ship the PEP 561 marker so downstream type checkers trust our inline hints.
stackvox = ["py.typed"]
# Ship the PEP 561 marker so downstream type checkers trust our inline hints,
# and the bash helper as package data — extracted onto PATH by
# `stackvox install-helper`. (We deliberately don't use [tool.setuptools]
# script-files = [...]: it's discouraged, has no PEP 621 equivalent, and
# doesn't round-trip through hatchling/uv if we ever migrate build backends.)
stackvox = ["py.typed", "data/stackvox-say"]

[tool.ruff]
line-length = 110
Expand Down
71 changes: 69 additions & 2 deletions stackvox/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,19 @@ def _configure_logging() -> None:
)


SUBCOMMANDS = {"serve", "stop", "status", "say", "speak", "voices", "welcome", "completion"}
SUBCOMMANDS = {
"serve",
"stop",
"status",
"say",
"speak",
"voices",
"welcome",
"completion",
"install-helper",
}

DEFAULT_HELPER_PREFIX = Path.home() / ".local" / "bin"

_BASH_COMPLETION = r"""# stackvox bash completion. Install with one of:
# eval "$(stackvox completion bash)" # current shell
Expand All @@ -34,7 +46,7 @@ def _configure_logging() -> None:
subcommand="${COMP_WORDS[1]:-}"

if [[ ${COMP_CWORD} -eq 1 ]]; then
COMPREPLY=( $(compgen -W "speak say serve stop status voices welcome completion" -- "$cur") )
COMPREPLY=( $(compgen -W "speak say serve stop status voices welcome completion install-helper" -- "$cur") )
return 0
fi

Expand All @@ -43,6 +55,10 @@ def _configure_logging() -> None:
COMPREPLY=( $(compgen -f -- "$cur") )
return 0
;;
--prefix)
COMPREPLY=( $(compgen -d -- "$cur") )
return 0
;;
--speed)
COMPREPLY=( $(compgen -W "0.8 0.9 1.0 1.1 1.2 1.5" -- "$cur") )
return 0
Expand All @@ -66,6 +82,9 @@ def _configure_logging() -> None:
completion)
COMPREPLY=( $(compgen -W "bash" -- "$cur") )
;;
install-helper)
COMPREPLY=( $(compgen -W "--prefix --help" -- "$cur") )
;;
*)
COMPREPLY=( $(compgen -W "--help" -- "$cur") )
;;
Expand Down Expand Up @@ -113,6 +132,17 @@ def _build_parser() -> argparse.ArgumentParser:
p_completion = sub.add_parser("completion", help="Print a shell completion script")
p_completion.add_argument("shell", choices=["bash"], help="Shell to generate completion for")

p_install_helper = sub.add_parser(
"install-helper",
help="Copy the stackvox-say bash helper onto PATH (default: ~/.local/bin)",
)
p_install_helper.add_argument(
"--prefix",
type=Path,
default=DEFAULT_HELPER_PREFIX,
help=f"Install directory (default: {DEFAULT_HELPER_PREFIX})",
)

return parser


Expand Down Expand Up @@ -221,6 +251,42 @@ def _cmd_completion(args: argparse.Namespace) -> int:
return 1


def _cmd_install_helper(args: argparse.Namespace) -> int:
"""Copy the bundled `stackvox-say` bash helper onto PATH.

The helper is shipped as package data (`stackvox/data/stackvox-say`) rather
than installed automatically by setuptools — `script-files` is discouraged
and doesn't round-trip through modern build backends. This subcommand is
the explicit one-time install step.
"""
import os
import shutil
import stat
from importlib.resources import as_file, files

prefix = Path(args.prefix).expanduser()
prefix.mkdir(parents=True, exist_ok=True)
dest = prefix / "stackvox-say"

src = files("stackvox").joinpath("data/stackvox-say")
with as_file(src) as src_path:
shutil.copy2(src_path, dest)

# Ensure executable bits regardless of how the wheel preserved them.
dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)

print(f"Installed stackvox-say to {dest}", file=sys.stderr)

path_dirs = os.environ.get("PATH", "").split(os.pathsep)
if str(prefix) not in path_dirs:
print(
f"Note: {prefix} is not on your PATH. Either add it to PATH or invoke "
f"the helper via its full path: {dest}",
file=sys.stderr,
)
return 0


def main() -> int:
_configure_logging()
argv = sys.argv[1:]
Expand All @@ -247,6 +313,7 @@ def main() -> int:
"voices": _cmd_voices,
"welcome": _cmd_welcome,
"completion": _cmd_completion,
"install-helper": _cmd_install_helper,
}
return handlers[args.cmd](args)

Expand Down
File renamed without changes.
39 changes: 39 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,3 +251,42 @@ def test_unsupported_shell_returns_one(self, capsys):
rc = cli._cmd_completion(_ns(shell="fish"))
assert rc == 1
assert "unsupported shell" in capsys.readouterr().err


class TestCmdInstallHelper:
def test_copies_helper_to_prefix_with_exec_bit(self, tmp_path):
prefix = tmp_path / "bin"
rc = cli._cmd_install_helper(_ns(prefix=prefix))

assert rc == 0
dest = prefix / "stackvox-say"
assert dest.exists()
# First line should be the bash shebang from the bundled script.
assert dest.read_text(encoding="utf-8").startswith("#!/bin/bash")
# Owner-execute bit is set.
import stat as st

assert dest.stat().st_mode & st.S_IXUSR

def test_creates_prefix_dir_if_missing(self, tmp_path):
prefix = tmp_path / "deep" / "nested" / "bin"
assert not prefix.exists()
rc = cli._cmd_install_helper(_ns(prefix=prefix))
assert rc == 0
assert (prefix / "stackvox-say").is_file()

def test_warns_when_prefix_not_on_path(self, tmp_path, mocker, capsys):
prefix = tmp_path / "out-of-path"
# Force PATH to a value that definitely doesn't include `prefix`.
mocker.patch.dict("os.environ", {"PATH": "/usr/bin:/bin"}, clear=False)
cli._cmd_install_helper(_ns(prefix=prefix))
err = capsys.readouterr().err
assert "not on your PATH" in err

def test_no_warning_when_prefix_on_path(self, tmp_path, mocker, capsys):
prefix = tmp_path / "on-path"
prefix.mkdir(parents=True)
# Put `prefix` on PATH so the warning shouldn't fire.
mocker.patch.dict("os.environ", {"PATH": f"{prefix}:/usr/bin"}, clear=False)
cli._cmd_install_helper(_ns(prefix=prefix))
assert "not on your PATH" not in capsys.readouterr().err