← Files Voice Notify for CodexARCHIVED FILE

scripts/voice_notify_config.ps1

10.8 KB · Oct 3, 2026 · 06:30 UTC

↓ Download file

param(
    [Parameter(Position = 0)]
    [ValidateSet("show", "set", "mute", "unmute", "reset", "test", "setup")]
    [string]$Command = "show",
    [ValidateSet("female", "male")]
    [string]$Voice,
    [ValidateSet("ko", "ja", "en", "ru", "zh-CN")]
    [string]$Language,
    [ValidateSet(
        "SessionStart",
        "UserPromptSubmit",
        "PreToolUse",
        "PostToolUse",
        "PermissionRequest",
        "PreCompact",
        "PostCompact",
        "SubagentStart",
        "SubagentStop",
        "Stop"
    )]
    [string]$Event = "Stop",
    [switch]$EnableEvent,
    [switch]$DisableEvent,
    [int]$MinIntervalMs = -1,
    [switch]$DryRun,
    [switch]$SkipAudioTest,
    [switch]$OpenHooks,
    [string]$CodexCommand
)

$ErrorActionPreference = "Stop"
$PluginRoot = Split-Path -Parent $PSScriptRoot
if ($env:CODEX_VOICE_NOTIFY_CONFIG) {
    $ConfigPath = [IO.Path]::GetFullPath($env:CODEX_VOICE_NOTIFY_CONFIG)
    $ConfigDirectory = Split-Path -Parent $ConfigPath
}
else {
    $ConfigDirectory = Join-Path $HOME ".config\codex-voice-notify"
    $ConfigPath = Join-Path $ConfigDirectory "settings.json"
}
$MinimumHooksCodexVersion = [version]"0.145.0"
$EventFiles = @{
    SessionStart      = "session-start"
    UserPromptSubmit  = "user-prompt-submit"
    PreToolUse        = "pre-tool-use"
    PostToolUse       = "post-tool-use"
    PermissionRequest = "permission-request"
    PreCompact        = "pre-compact"
    PostCompact       = "post-compact"
    SubagentStart     = "subagent-start"
    SubagentStop      = "subagent-stop"
    Stop              = "stop"
}

function ConvertTo-CanonicalLanguage($Value) {
    if ($Value -isnot [string]) {
        return $null
    }
    switch ($Value) {
        "ko" { return "ko" }
        "ja" { return "ja" }
        "en" { return "en" }
        "ru" { return "ru" }
        "zh-CN" { return "zh-CN" }
        default { return $null }
    }
}

function New-DefaultSettings {
    $Events = [ordered]@{}
    foreach ($Name in $EventFiles.Keys) {
        $Events[$Name] = @("PreToolUse", "PostToolUse") -notcontains $Name
    }
    return [ordered]@{
        enabled         = $true
        voice           = "female"
        language        = "ko"
        min_interval_ms = 450
        events          = $Events
    }
}

function Get-Settings {
    $Result = New-DefaultSettings
    if (-not (Test-Path -LiteralPath $ConfigPath -PathType Leaf)) {
        return $Result
    }
    try {
        $Candidate = Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json
        if ($Candidate.enabled -is [bool]) {
            $Result.enabled = $Candidate.enabled
        }
        if (@("female", "male") -contains $Candidate.voice) {
            $Result.voice = [string]$Candidate.voice
        }
        $CanonicalLanguage = ConvertTo-CanonicalLanguage $Candidate.language
        if ($null -ne $CanonicalLanguage) {
            $Result.language = $CanonicalLanguage
        }
        if ($Candidate.min_interval_ms -is [int] -or $Candidate.min_interval_ms -is [long]) {
            $Result.min_interval_ms = [Math]::Max(
                0,
                [Math]::Min(10000, [int]$Candidate.min_interval_ms)
            )
        }
        foreach ($Name in $EventFiles.Keys) {
            $Value = $Candidate.events.$Name
            if ($Value -is [bool]) {
                $Result.events[$Name] = $Value
            }
        }
    }
    catch {
        return New-DefaultSettings
    }
    return $Result
}

function Save-Settings($Settings) {
    [IO.Directory]::CreateDirectory($ConfigDirectory) | Out-Null
    $Temporary = Join-Path $ConfigDirectory ([IO.Path]::GetRandomFileName())
    $Json = $Settings | ConvertTo-Json -Depth 5
    $Utf8NoBom = New-Object Text.UTF8Encoding($false)
    try {
        [IO.File]::WriteAllText($Temporary, $Json + [Environment]::NewLine, $Utf8NoBom)
        Move-Item -LiteralPath $Temporary -Destination $ConfigPath -Force
    }
    finally {
        Remove-Item -LiteralPath $Temporary -Force -ErrorAction SilentlyContinue
    }
    Write-Output "Saved $ConfigPath"
}

function Get-CodexPath([string]$ExplicitPath) {
    if ($ExplicitPath) {
        return [IO.Path]::GetFullPath($ExplicitPath)
    }
    if ($env:CODEX_VOICE_NOTIFY_CODEX) {
        return [IO.Path]::GetFullPath($env:CODEX_VOICE_NOTIFY_CODEX)
    }
    foreach ($Name in @("codex.cmd", "codex.exe", "codex")) {
        $Candidate = Get-Command $Name -ErrorAction SilentlyContinue | Select-Object -First 1
        if ($null -ne $Candidate) {
            return $Candidate.Source
        }
    }
    return $null
}

function Get-CodexVersionInfo([string]$CodexPath) {
    try {
        $VersionOutput = (& $CodexPath --version 2>&1 | Out-String).Trim()
        $VersionExitCode = $LASTEXITCODE
    }
    catch {
        return $null
    }
    if ($VersionExitCode -ne 0) {
        return $null
    }
    if ($VersionOutput -notmatch "(?i)^\s*(?:openai\s+)?codex(?:-cli)?\s+(?:\(\s*)?v?(\d+)\.(\d+)\.(\d+)\s*\)?\s*$") {
        return $null
    }
    return [pscustomobject]@{
        Version = [version]("$($Matches[1]).$($Matches[2]).$($Matches[3])")
        Output  = $VersionOutput
    }
}

function Open-HookTrustTerminal([string]$CodexPath) {
    $EscapedCodexPath = $CodexPath.Replace("'", "''")
    $EscapedWorkingDirectory = (Get-Location).Path.Replace("'", "''")
    $LaunchCommand = @"
`$Host.UI.RawUI.WindowTitle = "Voice Notify - type /hooks in Codex"
Write-Host ""
Write-Host "Voice Notify opened this new Codex CLI terminal."
Write-Host "Type /hooks here and review the bundled hook."
Write-Host ""
& '$EscapedCodexPath' --no-alt-screen -C '$EscapedWorkingDirectory'
"@
    $EncodedCommand = [Convert]::ToBase64String(
        [Text.Encoding]::Unicode.GetBytes($LaunchCommand)
    )
    $PowerShellPath = (Get-Process -Id $PID).Path
    try {
        Start-Process -FilePath $PowerShellPath `
            -ArgumentList @(
                "-NoLogo",
                "-NoProfile",
                "-ExecutionPolicy",
                "Bypass",
                "-NoExit",
                "-EncodedCommand",
                $EncodedCommand
            ) `
            -WindowStyle Normal | Out-Null
    }
    catch {
        return $false
    }
    return $true
}

$Settings = Get-Settings
if ($Language) {
    $Language = ConvertTo-CanonicalLanguage $Language
}
switch ($Command) {
    "show" {
        $Settings | ConvertTo-Json -Depth 5
        exit 0
    }
    "reset" {
        Remove-Item -LiteralPath $ConfigPath -Force -ErrorAction SilentlyContinue
        Write-Output "Defaults restored."
        exit 0
    }
    "mute" {
        $Settings.enabled = $false
        Save-Settings $Settings
        exit 0
    }
    "unmute" {
        $Settings.enabled = $true
        Save-Settings $Settings
        exit 0
    }
    "set" {
        if ($Voice) {
            $Settings.voice = $Voice
        }
        if ($Language) {
            $Settings.language = $Language
        }
        if ($MinIntervalMs -ge 0) {
            $Settings.min_interval_ms = [Math]::Min(10000, $MinIntervalMs)
        }
        if ($EnableEvent) {
            $Settings.events[$Event] = $true
        }
        if ($DisableEvent) {
            $Settings.events[$Event] = $false
        }
        Save-Settings $Settings
        exit 0
    }
    "test" {
        if ($Voice) {
            $Settings.voice = $Voice
        }
        if ($Language) {
            $Settings.language = $Language
        }
        $AudioPath = Join-Path $PluginRoot (
            "assets\audio\$($Settings.voice)\$($Settings.language)\$($EventFiles[$Event]).wav"
        )
        if (-not (Test-Path -LiteralPath $AudioPath -PathType Leaf)) {
            throw "Audio file is unavailable: $AudioPath"
        }
        Write-Output $AudioPath
        if (-not $DryRun) {
            $Player = New-Object System.Media.SoundPlayer($AudioPath)
            $Player.PlaySync()
        }
        exit 0
    }
    "setup" {
        if ($Voice) {
            $Settings.voice = $Voice
        }
        if ($Language) {
            $Settings.language = $Language
        }
        $Settings.enabled = $true

        $ResolvedCodexPath = Get-CodexPath $CodexCommand
        if (-not $ResolvedCodexPath) {
            [Console]::Error.WriteLine(
                "Codex CLI was not found. Install or expose Codex on PATH, then rerun setup."
            )
            exit 3
        }
        $CodexInfo = Get-CodexVersionInfo $ResolvedCodexPath
        if ($null -eq $CodexInfo) {
            [Console]::Error.WriteLine(
                "Could not determine the Codex CLI version from $ResolvedCodexPath."
            )
            exit 3
        }
        Write-Output "Codex CLI: $($CodexInfo.Version) ($ResolvedCodexPath)"
        if ($CodexInfo.Version -lt $MinimumHooksCodexVersion) {
            [Console]::Error.WriteLine((
                "Codex CLI {0} or newer is required for /hooks. Update Codex, then rerun setup." -f
                $MinimumHooksCodexVersion
            ))
            exit 3
        }

        $AudioPath = Join-Path $PluginRoot (
            "assets\audio\$($Settings.voice)\$($Settings.language)\$($EventFiles.Stop).wav"
        )
        if (-not (Test-Path -LiteralPath $AudioPath -PathType Leaf)) {
            [Console]::Error.WriteLine("Stop audio file is unavailable: $AudioPath")
            exit 1
        }
        Write-Output "Stop audio: $AudioPath"
        if (-not $DryRun -and -not $SkipAudioTest) {
            $Player = New-Object System.Media.SoundPlayer($AudioPath)
            $Player.PlaySync()
        }

        if ($DryRun) {
            Write-Output (
                "Dry run: would save voice={0} language={1}." -f
                $Settings.voice,
                $Settings.language
            )
        }
        else {
            Save-Settings $Settings
        }

        if ($OpenHooks) {
            if ($DryRun) {
                Write-Output (
                    "Dry run: would open a new terminal and start Codex CLI " +
                    "for manual /hooks review."
                )
            }
            else {
                if (Open-HookTrustTerminal $ResolvedCodexPath) {
                    Write-Output (
                        "Started Codex CLI in a new terminal window. Type /hooks " +
                        "in that window, review the Voice Notify hook, and trust it."
                    )
                }
                else {
                    [Console]::Error.WriteLine(
                        "Could not open a new Codex CLI terminal automatically. Start Codex CLI yourself, enter /hooks, and review the Voice Notify hook."
                    )
                    exit 4
                }
            }
        }
        else {
            Write-Output "Next: run Codex, enter /hooks, and review the Voice Notify hook."
        }
        Write-Output "After trust is granted, fully restart Codex before testing lifecycle events."
        exit 0
    }
}

SHA-256: c5489f499f6e4d31aa097d0e5c6eac29cfc3175dbd6f4bee1786858e82d421bc