Skip to content
Open
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
15 changes: 14 additions & 1 deletion Packs/Utilities/INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,20 @@ Check that the sub-skill's SKILL.md exists in its directory. The routing table i
### Sub-skill workflow fails with missing dependency

Individual sub-skills may require external tools:
- **AudioEditor**: Install ffmpeg (`brew install ffmpeg`)
- **AudioEditor**: Install ffmpeg
<details><summary>macOS</summary>

```bash
brew install ffmpeg
```
</details>
<details><summary>Linux / WSL</summary>

```bash
sudo apt-get install -y ffmpeg # Debian/Ubuntu/WSL
sudo dnf install -y ffmpeg # Fedora
```
</details>
- **Cloudflare**: Install wrangler (`npm install -g wrangler`)
- **Fabric**: Install fabric (see fabric docs)
- **CreateCLI, Evals, PAIUpgrade, Parser**: Install bun (`curl -fsSL https://bun.sh/install | bash`)
Expand Down
173 changes: 144 additions & 29 deletions Releases/v4.0.3/.claude/VoiceServer/install.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
#!/bin/bash

# Voice Server Installation Script
# This script installs the voice server as a macOS service
# Installs the voice server as a system service:
# - macOS → launchctl LaunchAgent (unchanged, byte-identical)
# - Linux → systemd --user unit
# - WSL2 → systemd --user unit (requires systemd-on-WSL enabled)

set -e

Expand All @@ -14,9 +17,14 @@ NC='\033[0m' # No Color

# Configuration
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
# shellcheck source=lib/platform.sh
. "$SCRIPT_DIR/lib/platform.sh"
SERVICE_NAME="com.pai.voice-server"
PLIST_PATH="$HOME/Library/LaunchAgents/${SERVICE_NAME}.plist"
LOG_PATH="$HOME/Library/Logs/pai-voice-server.log"
SYSTEMD_UNIT_NAME="pai-voice.service"
SYSTEMD_UNIT_DIR="$HOME/.config/systemd/user"
SYSTEMD_UNIT_PATH="$SYSTEMD_UNIT_DIR/$SYSTEMD_UNIT_NAME"
LOG_PATH="$(pai_log_path)"
ENV_FILE="$HOME/.env"

echo -e "${BLUE}=====================================================${NC}"
Expand All @@ -35,35 +43,86 @@ fi
echo -e "${GREEN}OK Bun is installed${NC}"

# Check for existing installation
if launchctl list | grep -q "$SERVICE_NAME" 2>/dev/null; then
echo -e "${YELLOW}! Voice server is already installed${NC}"
read -p "Do you want to reinstall? (y/n): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo -e "${YELLOW}> Stopping existing service...${NC}"
launchctl unload "$PLIST_PATH" 2>/dev/null || true
echo -e "${GREEN}OK Existing service stopped${NC}"
else
echo "Installation cancelled"
exit 0
# Darwin path preserved byte-identical. On Linux/WSL we probe the
# systemd --user unit; user is prompted the same way as on macOS.
if pai_is_darwin; then
if launchctl list | grep -q "$SERVICE_NAME" 2>/dev/null; then
echo -e "${YELLOW}! Voice server is already installed${NC}"
read -p "Do you want to reinstall? (y/n): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo -e "${YELLOW}> Stopping existing service...${NC}"
launchctl unload "$PLIST_PATH" 2>/dev/null || true
echo -e "${GREEN}OK Existing service stopped${NC}"
else
echo "Installation cancelled"
exit 0
fi
fi
else
# Require systemd --user to be reachable. On bare Linux this is
# the default; on WSL2 it requires systemd-on-WSL to be enabled
# (/etc/wsl.conf: [boot] systemd=true). Fail loudly if not.
if ! command -v systemctl >/dev/null 2>&1; then
echo -e "${RED}X systemctl not found${NC}"
echo " The Linux voice server installer requires systemd."
echo " Install or enable systemd and re-run this script."
exit 1
fi
if ! systemctl --user list-units --no-pager >/dev/null 2>&1; then
echo -e "${RED}X systemd --user session is not reachable${NC}"
if pai_is_wsl; then
echo " On WSL2, enable systemd by adding the following to"
echo " /etc/wsl.conf and running 'wsl --shutdown' from Windows:"
echo " [boot]"
echo " systemd=true"
else
echo " Ensure systemd --user is available for your session"
echo " (user@${UID}.service should be running)."
fi
exit 1
fi
if [ -f "$SYSTEMD_UNIT_PATH" ] \
|| systemctl --user list-unit-files "$SYSTEMD_UNIT_NAME" 2>/dev/null | grep -q "$SYSTEMD_UNIT_NAME"; then
echo -e "${YELLOW}! Voice server is already installed${NC}"
read -p "Do you want to reinstall? (y/n): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo -e "${YELLOW}> Stopping existing service...${NC}"
systemctl --user stop "$SYSTEMD_UNIT_NAME" 2>/dev/null || true
systemctl --user disable "$SYSTEMD_UNIT_NAME" 2>/dev/null || true
echo -e "${GREEN}OK Existing service stopped${NC}"
else
echo "Installation cancelled"
exit 0
fi
fi
fi

# Check for ElevenLabs configuration
echo -e "${YELLOW}> Checking ElevenLabs configuration...${NC}"
# Platform-aware fallback message — the historical string mentioned
# macOS 'say' regardless of host OS, which is misleading on Linux/WSL
# where no built-in TTS fallback exists. The Darwin message is kept
# byte-identical so the macOS flow reads the same as before.
if pai_is_darwin; then
FALLBACK_NOTE="Voice server will use macOS 'say' command as fallback"
else
FALLBACK_NOTE="Voice server will have no TTS fallback without an ElevenLabs API key"
fi
if [ -f "$ENV_FILE" ] && grep -q "ELEVENLABS_API_KEY=" "$ENV_FILE"; then
API_KEY=$(grep "ELEVENLABS_API_KEY=" "$ENV_FILE" | cut -d'=' -f2)
if [ "$API_KEY" != "your_api_key_here" ] && [ -n "$API_KEY" ]; then
echo -e "${GREEN}OK ElevenLabs API key configured${NC}"
ELEVENLABS_CONFIGURED=true
else
echo -e "${YELLOW}! ElevenLabs API key not configured${NC}"
echo " Voice server will use macOS 'say' command as fallback"
echo " $FALLBACK_NOTE"
ELEVENLABS_CONFIGURED=false
fi
else
echo -e "${YELLOW}! No ElevenLabs configuration found${NC}"
echo " Voice server will use macOS 'say' command as fallback"
echo " $FALLBACK_NOTE"
ELEVENLABS_CONFIGURED=false
fi

Expand All @@ -75,11 +134,17 @@ if [ "$ELEVENLABS_CONFIGURED" = false ]; then
echo
fi

# Create LaunchAgent plist
echo -e "${YELLOW}> Creating LaunchAgent configuration...${NC}"
mkdir -p "$HOME/Library/LaunchAgents"
# Create and load the service unit (platform branch)
# Darwin path preserved byte-identical: same plist content, same
# launchctl load invocation. Linux/WSL writes a systemd --user unit
# at ~/.config/systemd/user/pai-voice.service, templated after the
# reference unit that ships with PAI on WSL2.
if pai_is_darwin; then
# Create LaunchAgent plist
echo -e "${YELLOW}> Creating LaunchAgent configuration...${NC}"
mkdir -p "$HOME/Library/LaunchAgents"

cat > "$PLIST_PATH" << EOF
cat > "$PLIST_PATH" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
Expand Down Expand Up @@ -123,15 +188,59 @@ cat > "$PLIST_PATH" << EOF
</plist>
EOF

echo -e "${GREEN}OK LaunchAgent configuration created${NC}"
echo -e "${GREEN}OK LaunchAgent configuration created${NC}"

# Load the LaunchAgent
echo -e "${YELLOW}> Starting voice server service...${NC}"
launchctl load "$PLIST_PATH" 2>/dev/null || {
echo -e "${RED}X Failed to load LaunchAgent${NC}"
echo " Try manually: launchctl load $PLIST_PATH"
exit 1
}
# Load the LaunchAgent
echo -e "${YELLOW}> Starting voice server service...${NC}"
launchctl load "$PLIST_PATH" 2>/dev/null || {
echo -e "${RED}X Failed to load LaunchAgent${NC}"
echo " Try manually: launchctl load $PLIST_PATH"
exit 1
}
else
# Create systemd --user unit
echo -e "${YELLOW}> Creating systemd user unit...${NC}"
mkdir -p "$SYSTEMD_UNIT_DIR"
mkdir -p "$(dirname "$LOG_PATH")"

BUN_BIN="$(command -v bun)"
if [ -z "$BUN_BIN" ]; then
echo -e "${RED}X Could not locate bun on PATH${NC}"
exit 1
fi

cat > "$SYSTEMD_UNIT_PATH" << EOF
[Unit]
Description=PAI Voice Server (ElevenLabs TTS)
After=default.target

[Service]
Type=simple
WorkingDirectory=${SCRIPT_DIR}
ExecStart=${BUN_BIN} run server.ts
Restart=on-failure
RestartSec=3
StandardOutput=append:${LOG_PATH}
StandardError=append:${LOG_PATH}
Environment=HOME=${HOME}
Environment=PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${HOME}/.bun/bin

[Install]
WantedBy=default.target
EOF

echo -e "${GREEN}OK systemd unit written to $SYSTEMD_UNIT_PATH${NC}"

# Load and start the service
echo -e "${YELLOW}> Starting voice server service...${NC}"
systemctl --user daemon-reload
systemctl --user enable --now "$SYSTEMD_UNIT_NAME" || {
echo -e "${RED}X Failed to start systemd unit${NC}"
echo " Try manually: systemctl --user status $SYSTEMD_UNIT_NAME"
echo " Logs: journalctl --user -u $SYSTEMD_UNIT_NAME -n 50"
exit 1
}
fi

# Wait for server to start
sleep 2
Expand Down Expand Up @@ -161,15 +270,21 @@ echo -e "${GREEN} Installation Complete!${NC}"
echo -e "${GREEN}=====================================================${NC}"
echo
echo -e "${BLUE}Service Information:${NC}"
echo " - Service: $SERVICE_NAME"
if pai_is_darwin; then
echo " - Service: $SERVICE_NAME (launchd)"
else
echo " - Service: $SYSTEMD_UNIT_NAME (systemd --user)"
fi
echo " - Status: Running"
echo " - Port: 8888"
echo " - Logs: $LOG_PATH"

if [ "$ELEVENLABS_CONFIGURED" = true ]; then
echo " - Voice: ElevenLabs AI"
else
elif pai_is_darwin; then
echo " - Voice: macOS Say (fallback)"
else
echo " - Voice: none (configure ElevenLabs API key in ~/.env)"
fi

echo
Expand Down
130 changes: 130 additions & 0 deletions Releases/v4.0.3/.claude/VoiceServer/lib/platform.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#!/usr/bin/env bash
# PAI VoiceServer — shared platform detection and path helpers.
#
# Designed to be sourced by sibling scripts (install.sh, start.sh, stop.sh,
# status.sh, uninstall.sh, menubar/pai-voice.5s.sh). POSIX-leaning bash: no
# associative arrays, no process substitution in function bodies, works under
# `bash` on macOS and Linux/WSL2.
#
# Exports three helpers:
# pai_is_wsl — returns 0 iff running inside WSL (any version)
# pai_log_path — prints the platform-appropriate log file path
# pai_port_pids PORT — prints PIDs listening on PORT (lsof > ss > netstat)
#
# Platform matrix:
# macOS (darwin): log path = $HOME/Library/Logs/pai-voice-server.log
# (byte-identical to pre-refactor literal)
# Linux + WSL2: log path = ${XDG_DATA_HOME:-$HOME/.local/share}/pai/logs/pai-voice-server.log
#
# This file is intentionally side-effect free on source — no echo, no exit,
# no directory creation. Callers decide when to mkdir the log directory.

# --------------------------------------------------------------------------
# OS detection
# --------------------------------------------------------------------------

pai_uname_s() {
uname -s 2>/dev/null || echo "Unknown"
}

pai_is_darwin() {
[ "$(pai_uname_s)" = "Darwin" ]
}

pai_is_linux() {
[ "$(pai_uname_s)" = "Linux" ]
}

# pai_is_wsl — true iff /proc/version mentions Microsoft (WSL1 or WSL2).
# Case-insensitive match catches both "Microsoft" (WSL1) and "microsoft" (WSL2).
pai_is_wsl() {
if ! pai_is_linux; then
return 1
fi
if [ ! -r /proc/version ]; then
return 1
fi
if grep -qi 'microsoft' /proc/version; then
return 0
fi
return 1
}

# --------------------------------------------------------------------------
# Paths
# --------------------------------------------------------------------------

# pai_log_path — prints the absolute log file path for pai-voice-server.
# On macOS preserves the historical ~/Library/Logs location (byte-identical
# to the pre-refactor literal) so the Darwin flow stays untouched.
# On Linux/WSL uses XDG_DATA_HOME with a sane fallback.
pai_log_path() {
if pai_is_darwin; then
printf '%s\n' "$HOME/Library/Logs/pai-voice-server.log"
return 0
fi
# Linux or WSL — XDG Base Directory spec.
local base="${XDG_DATA_HOME:-$HOME/.local/share}"
printf '%s\n' "$base/pai/logs/pai-voice-server.log"
}

# --------------------------------------------------------------------------
# Port helpers
# --------------------------------------------------------------------------

# pai_port_pids PORT — prints one PID per line that is listening on PORT.
# Tries lsof first (historic behavior on macOS), then ss (iproute2, ubiquitous
# on modern Linux/WSL), then netstat as a last resort. Prints nothing and
# returns 1 if none are available or nothing is listening.
pai_port_pids() {
local port="$1"
if [ -z "$port" ]; then
return 2
fi

if command -v lsof >/dev/null 2>&1; then
local pids
pids=$(lsof -ti ":${port}" 2>/dev/null)
if [ -n "$pids" ]; then
printf '%s\n' "$pids"
return 0
fi
fi

if command -v ss >/dev/null 2>&1; then
# ss -H -ltnp sport = :PORT — machine-readable, one line per socket.
# Extract pid=NNN from users:(("name",pid=NNN,fd=N)).
local out
out=$(ss -H -ltnp "sport = :${port}" 2>/dev/null \
| grep -oE 'pid=[0-9]+' \
| cut -d= -f2 \
| sort -u)
if [ -n "$out" ]; then
printf '%s\n' "$out"
return 0
fi
fi

if command -v netstat >/dev/null 2>&1; then
# netstat -ltnp, extract "PID/program" from the last column.
local out
out=$(netstat -ltnp 2>/dev/null \
| awk -v p=":${port}" '$4 ~ p"$" {print $7}' \
| cut -d/ -f1 \
| grep -E '^[0-9]+$' \
| sort -u)
if [ -n "$out" ]; then
printf '%s\n' "$out"
return 0
fi
fi

return 1
}

# pai_port_in_use PORT — 0 iff something is listening on PORT.
pai_port_in_use() {
local pids
pids=$(pai_port_pids "$1") || return 1
[ -n "$pids" ]
}
Loading