← Files Voice Notify for CodexARCHIVED FILE

scripts/voice_notify_config.py

10.8 KB · Oct 2, 2026 · 00:29 UTC

↓ Download file

#!/usr/bin/env python3
"""Configure and test Voice Notify for Codex without external dependencies."""

from __future__ import annotations

import argparse
import json
import os
import pathlib
import re
import shlex
import shutil
import subprocess
import sys
import tempfile
from typing import Optional, Tuple


PLUGIN_ROOT = pathlib.Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PLUGIN_ROOT / "hooks"))
import play_notify  # noqa: E402


MIN_HOOKS_CODEX_VERSION = (0, 145, 0)
LANGUAGE_CHOICES = tuple(sorted(play_notify.LANGUAGES))
CODEX_VERSION_PATTERN = re.compile(
    r"(?i)^\s*(?:openai\s+)?codex(?:-cli)?\s+"
    r"(?:\(\s*)?v?(\d+)\.(\d+)\.(\d+)\s*\)?\s*$"
)


def parse_codex_version(output: str) -> Optional[Tuple[int, int, int]]:
    match = CODEX_VERSION_PATTERN.search(output)
    if match is None:
        return None
    return tuple(int(part) for part in match.groups())


def find_codex_command(override: Optional[str] = None) -> Optional[pathlib.Path]:
    selected = override or os.environ.get("CODEX_VOICE_NOTIFY_CODEX")
    if selected:
        return pathlib.Path(selected).expanduser()
    discovered = shutil.which("codex")
    return pathlib.Path(discovered) if discovered else None


def inspect_codex(override: Optional[str] = None) -> Tuple[
    Optional[pathlib.Path], Optional[Tuple[int, int, int]], str
]:
    command = find_codex_command(override)
    if command is None:
        return None, None, ""
    try:
        completed = subprocess.run(
            (str(command), "--version"),
            stdin=subprocess.DEVNULL,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
            timeout=15,
            check=False,
        )
    except (OSError, subprocess.TimeoutExpired):
        return command, None, ""
    output = completed.stdout.strip()
    if completed.returncode != 0:
        return command, None, output
    return command, parse_codex_version(output), output


def _apple_script_string(value: str) -> str:
    return value.replace("\\", "\\\\").replace('"', '\\"')


def open_hook_trust_terminal(command: pathlib.Path, cwd: pathlib.Path) -> bool:
    if sys.platform != "darwin":
        return False
    osascript = pathlib.Path("/usr/bin/osascript")
    if not osascript.is_file():
        return False
    shell_command = (
        "printf '\\nVoice Notify opened this new Codex CLI terminal. "
        "Type /hooks here and review the bundled hook.\\n\\n'; "
        "exec %s --no-alt-screen -C %s"
        % (shlex.quote(str(command)), shlex.quote(str(cwd)))
    )
    apple_script = (
        'tell application "Terminal"\n'
        "activate\n"
        'do script "%s"\n'
        "end tell"
        % _apple_script_string(shell_command)
    )
    try:
        completed = subprocess.run(
            (str(osascript), "-e", apple_script),
            stdin=subprocess.DEVNULL,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            timeout=15,
            check=False,
        )
    except (OSError, subprocess.TimeoutExpired):
        return False
    return completed.returncode == 0


def write_settings(settings: dict) -> pathlib.Path:
    destination = play_notify.user_settings_path()
    destination.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
    handle = tempfile.NamedTemporaryFile(
        mode="w",
        encoding="utf-8",
        dir=str(destination.parent),
        prefix=".settings.",
        suffix=".tmp",
        delete=False,
    )
    temporary = pathlib.Path(handle.name)
    try:
        with handle:
            json.dump(settings, handle, ensure_ascii=False, indent=2, sort_keys=True)
            handle.write("\n")
            handle.flush()
            os.fsync(handle.fileno())
        os.chmod(temporary, 0o600)
        os.replace(temporary, destination)
    finally:
        try:
            temporary.unlink()
        except FileNotFoundError:
            pass
    return destination


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Configure Voice Notify for Codex")
    subparsers = parser.add_subparsers(dest="command", required=True)
    subparsers.add_parser("show", help="Show effective settings")
    subparsers.add_parser("mute", help="Disable notifications")
    subparsers.add_parser("unmute", help="Enable notifications")
    subparsers.add_parser("reset", help="Restore built-in defaults")

    set_parser = subparsers.add_parser("set", help="Change voice, language, or events")
    set_parser.add_argument("--voice", choices=sorted(play_notify.VOICES))
    set_parser.add_argument("--language", choices=LANGUAGE_CHOICES)
    set_parser.add_argument("--min-interval-ms", type=int)
    set_parser.add_argument(
        "--enable-event", action="append", choices=sorted(play_notify.EVENT_FILES)
    )
    set_parser.add_argument(
        "--disable-event", action="append", choices=sorted(play_notify.EVENT_FILES)
    )

    test_parser = subparsers.add_parser("test", help="Play one notification")
    test_parser.add_argument("--event", choices=sorted(play_notify.EVENT_FILES), default="Stop")
    test_parser.add_argument("--voice", choices=sorted(play_notify.VOICES))
    test_parser.add_argument("--language", choices=LANGUAGE_CHOICES)
    test_parser.add_argument("--dry-run", action="store_true")

    setup_parser = subparsers.add_parser(
        "setup", help="Run guided first-time setup"
    )
    setup_parser.add_argument("--voice", choices=sorted(play_notify.VOICES))
    setup_parser.add_argument("--language", choices=LANGUAGE_CHOICES)
    setup_parser.add_argument("--skip-audio-test", action="store_true")
    setup_parser.add_argument("--open-hooks", action="store_true")
    setup_parser.add_argument("--codex-command")
    setup_parser.add_argument("--dry-run", action="store_true")
    return parser


def run_setup(args: argparse.Namespace, settings: dict) -> int:
    if args.voice:
        settings["voice"] = args.voice
    if args.language:
        settings["language"] = args.language
    settings["enabled"] = True

    codex_command, codex_version, codex_output = inspect_codex(args.codex_command)
    if codex_command is None:
        print(
            "Codex CLI was not found. Install or expose Codex on PATH, then rerun setup.",
            file=sys.stderr,
        )
        return 3
    if codex_version is None:
        print(
            "Could not determine the Codex CLI version from: %s"
            % (codex_output or codex_command),
            file=sys.stderr,
        )
        return 3
    version_text = ".".join(str(part) for part in codex_version)
    minimum_text = ".".join(str(part) for part in MIN_HOOKS_CODEX_VERSION)
    print("Codex CLI: %s (%s)" % (version_text, codex_command))
    if codex_version < MIN_HOOKS_CODEX_VERSION:
        print(
            "Codex CLI %s or newer is required for /hooks. Update Codex, then rerun setup."
            % minimum_text,
            file=sys.stderr,
        )
        return 3

    test_settings = dict(settings)
    test_settings["events"] = dict(settings["events"])
    test_settings["events"]["Stop"] = True
    audio_path = play_notify.select_audio_path(PLUGIN_ROOT, "Stop", test_settings)
    if audio_path is None:
        print("Stop audio file is unavailable.", file=sys.stderr)
        return 1
    print("Stop audio: %s" % audio_path)
    if not args.dry_run and not args.skip_audio_test:
        if not play_notify.player_available():
            print("No supported system WAV player is available.", file=sys.stderr)
            return 1
        playback_status = play_notify.play_audio_sync(audio_path)
        if playback_status != 0:
            return playback_status

    if args.dry_run:
        print(
            "Dry run: would save voice=%s language=%s."
            % (settings["voice"], settings["language"])
        )
    else:
        destination = write_settings(settings)
        print("Saved %s" % destination)

    if args.open_hooks:
        if args.dry_run:
            print(
                "Dry run: would open a new terminal and start Codex CLI "
                "for manual /hooks review."
            )
        elif open_hook_trust_terminal(codex_command, pathlib.Path.cwd()):
            print(
                "Started Codex CLI in a new Terminal window. Type /hooks in "
                "that window, review the Voice Notify hook, and trust it."
            )
        else:
            print(
                "Could not open a new Codex CLI Terminal automatically. Start "
                "Codex CLI yourself, enter /hooks, and review the Voice Notify hook.",
                file=sys.stderr,
            )
            return 4
    else:
        print("Next: run Codex, enter /hooks, and review the Voice Notify hook.")
    print("After trust is granted, fully restart Codex before testing lifecycle events.")
    return 0


def main() -> int:
    args = build_parser().parse_args()
    settings = play_notify.load_settings()

    if args.command == "show":
        print(json.dumps(settings, ensure_ascii=False, indent=2, sort_keys=True))
        return 0
    if args.command == "reset":
        try:
            play_notify.user_settings_path().unlink()
        except FileNotFoundError:
            pass
        print("Defaults restored.")
        return 0
    if args.command in ("mute", "unmute"):
        settings["enabled"] = args.command == "unmute"
        destination = write_settings(settings)
        print("Saved %s" % destination)
        return 0
    if args.command == "set":
        if args.voice:
            settings["voice"] = args.voice
        if args.language:
            settings["language"] = args.language
        if args.min_interval_ms is not None:
            settings["min_interval_ms"] = max(0, min(args.min_interval_ms, 10_000))
        for event in args.enable_event or ():
            settings["events"][event] = True
        for event in args.disable_event or ():
            settings["events"][event] = False
        destination = write_settings(settings)
        print("Saved %s" % destination)
        return 0
    if args.command == "test":
        if args.voice:
            settings["voice"] = args.voice
        if args.language:
            settings["language"] = args.language
        settings["enabled"] = True
        settings["events"][args.event] = True
        audio_path = play_notify.select_audio_path(PLUGIN_ROOT, args.event, settings)
        if audio_path is None:
            print("Audio file is unavailable.", file=sys.stderr)
            return 1
        print(str(audio_path))
        if args.dry_run:
            return 0
        if not play_notify.player_available():
            print("No supported system WAV player is available.", file=sys.stderr)
            return 1
        return play_notify.play_audio_sync(audio_path)
    if args.command == "setup":
        return run_setup(args, settings)
    return 2


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: 10392764c2460bad794572da433113b9c4bf893acce6658f119d8bdbdafbf8cd