diff --git a/Makefile b/Makefile index eadc87cff..a0dd77668 100644 --- a/Makefile +++ b/Makefile @@ -2,12 +2,15 @@ # SPDX-License-Identifier: Apache-2.0 .DEFAULT_GOAL := help -.PHONY: help install-linux install-linux-dry-run uninstall-linux +.PHONY: help install-linux install-linux-dry-run uninstall-linux install-macos install-macos-dry-run uninstall-macos help: @echo "install-linux Install the systemd user service and Codex profile" @echo "install-linux-dry-run Preview installation without changes" @echo "uninstall-linux Remove the service and Codex profile" + @echo "install-macos Install the background server" + @echo "install-macos-dry-run Preview installation without changes" + @echo "uninstall-macos Remove the LaunchAgents and Codex profile" ## Install the Switchyard background server as a systemd user service. install-linux: @@ -20,3 +23,15 @@ install-linux-dry-run: ## Remove the systemd user service, the sy Codex profile, and the codex alias. uninstall-linux: @scripts/linux/uninstall.sh + +## Install the Switchyard background server for this user. +install-macos: + @scripts/macos/install.sh + +## Print what install-macos would do, without changing anything. +install-macos-dry-run: + @scripts/macos/install.sh --dry-run + +## Remove the LaunchAgent, the sy Codex profile, and the codex alias. +uninstall-macos: + @scripts/macos/uninstall.sh diff --git a/scripts/common.sh b/scripts/common.sh new file mode 100644 index 000000000..cd27254f1 --- /dev/null +++ b/scripts/common.sh @@ -0,0 +1,117 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +SY_HOME="${SY_HOME:-$HOME/.switchyard}" +SY_PORT="${SY_PORT:-4123}" +CODEX_DIR="${CODEX_HOME:-$HOME/.codex}" +# Both installers use Codex's standalone profile file for the CLI. +CODEX_PROFILE_CONFIG="$CODEX_DIR/sy.config.toml" +ALIAS_START="# >>> switchyard codex alias >>>" +ALIAS_END="# <<< switchyard codex alias <<<" + +say() { printf '%s\n' "$*"; } +step() { printf '\n==> %s\n' "$*"; } + +strip_block() { + local path="$1" start="$2" end="$3" label="${4:-the switchyard block}" + if [[ ! -f "$path" ]] || ! grep -qF "$start" "$path"; then + return 1 + fi + if ! grep -qF "$end" "$path"; then + say " $path has no end marker for $label; not editing it" >&2 + return 1 + fi + if (( DRY_RUN )); then + say " would remove $label from $path" + return 0 + fi + local temp + temp="$(mktemp)" + awk -v start="$start" -v end="$end" ' + index($0, start) { skipping = 1 } + !skipping { print } + index($0, end) { skipping = 0 } + END { if (skipping) exit 1 } + ' "$path" > "$temp" + cat "$temp" > "$path" + rm -f "$temp" + say " removed $label from $path" + return 0 +} + +run() { + if (( DRY_RUN )); then + say " would run: $*" + else + "$@" + fi +} + +write_once() { + local path="$1" + if [[ -f "$path" ]]; then + say " keeping existing $path" + cat >/dev/null + return + fi + if (( DRY_RUN )); then + say " would create $path" + cat >/dev/null + else + mkdir -p "$(dirname "$path")" + cat > "$path" + say " created $path" + fi +} + +write_with_backup() { + local path="$1" + if (( DRY_RUN )); then + [[ -f "$path" ]] && say " would back up $path" + say " would write $path" + cat >/dev/null + return + fi + mkdir -p "$(dirname "$path")" + local incoming + incoming="$(mktemp)" + cat > "$incoming" + if [[ -f "$path" ]] && cmp -s "$incoming" "$path"; then + say " $path is already up to date" + rm -f "$incoming" + return + fi + if [[ -f "$path" ]]; then + local backup + backup="$path.switchyard-backup.$(date +%Y%m%d%H%M%S)" + cp "$path" "$backup" + say " backed up $path to $backup" + fi + cat "$incoming" > "$path" + rm -f "$incoming" + say " wrote $path" +} + +write_always() { + local path="$1" + if (( DRY_RUN )); then + say " would write $path" + cat >/dev/null + else + mkdir -p "$(dirname "$path")" + cat > "$path" + say " wrote $path" + fi +} + +remove_file() { + local path="$1" + if [[ ! -e "$path" ]]; then + say " nothing to remove at $path" + elif (( DRY_RUN )); then + say " would delete $path" + else + rm -f "$path" + say " deleted $path" + fi +} diff --git a/scripts/linux/common.sh b/scripts/linux/common.sh index 25b16dd83..3a9b7782e 100644 --- a/scripts/linux/common.sh +++ b/scripts/linux/common.sh @@ -1,47 +1,8 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# Paths, markers, and helpers shared by install.sh and uninstall.sh. -# The markers must match between the two, which is why they live here. +# shellcheck source=scripts/common.sh +source "$(dirname "${BASH_SOURCE[0]}")/../common.sh" -SY_HOME="${SY_HOME:-$HOME/.switchyard}" -SY_PORT="${SY_PORT:-4123}" SERVICE_NAME="switchyard.service" SYSTEMD_USER_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" -CODEX_DIR="${CODEX_HOME:-$HOME/.codex}" -# Since Codex 0.134.0, `--profile sy` reads sy.config.toml and fails if -# config.toml still has [profiles.sy]. -CODEX_PROFILE_CONFIG="$CODEX_DIR/sy.config.toml" -ALIAS_START="# >>> switchyard codex alias >>>" -ALIAS_END="# <<< switchyard codex alias <<<" - -say() { printf '%s\n' "$*"; } -step() { printf '\n==> %s\n' "$*"; } - -# Deletes the marked block, inclusive, leaving the rest of the file alone. -strip_block() { - local path="$1" start="$2" end="$3" label="${4:-the switchyard block}" - if [[ ! -f "$path" ]] || ! grep -qF "$start" "$path"; then - return 1 - fi - if ! grep -qF "$end" "$path"; then - say " $path has no end marker for $label; not editing it" >&2 - return 1 - fi - if (( DRY_RUN )); then - say " would remove $label from $path" - return 0 - fi - local temp - temp="$(mktemp)" - awk -v start="$start" -v end="$end" ' - index($0, start) { skipping = 1 } - !skipping { print } - index($0, end) { skipping = 0 } - END { if (skipping) exit 1 } - ' "$path" > "$temp" - cat "$temp" > "$path" - rm -f "$temp" - say " removed $label from $path" - return 0 -} diff --git a/scripts/linux/install.sh b/scripts/linux/install.sh index 247bf8c6f..c296ebcfc 100755 --- a/scripts/linux/install.sh +++ b/scripts/linux/install.sh @@ -34,75 +34,6 @@ if [[ ! "$SY_PORT" =~ ^[0-9]+$ ]]; then exit 1 fi -# Runs a command, or prints it when dry running. -run() { - if (( DRY_RUN )); then - say " would run: $*" - else - "$@" - fi -} - -# Writes stdin to a file, leaving an existing file untouched. -write_once() { - local path="$1" - if [[ -f "$path" ]]; then - say " keeping existing $path" - cat >/dev/null - return - fi - if (( DRY_RUN )); then - say " would create $path" - cat >/dev/null - else - mkdir -p "$(dirname "$path")" - cat > "$path" - say " created $path" - fi -} - -# Writes stdin to a file, keeping any existing version as a timestamped backup. -write_with_backup() { - local path="$1" - if (( DRY_RUN )); then - [[ -f "$path" ]] && say " would back up $path" - say " would write $path" - cat >/dev/null - return - fi - mkdir -p "$(dirname "$path")" - local incoming - incoming="$(mktemp)" - cat > "$incoming" - if [[ -f "$path" ]] && cmp -s "$incoming" "$path"; then - say " $path is already up to date" - rm -f "$incoming" - return - fi - if [[ -f "$path" ]]; then - local backup - backup="$path.switchyard-backup.$(date +%Y%m%d%H%M%S)" - cp "$path" "$backup" - say " backed up $path to $backup" - fi - cat "$incoming" > "$path" - rm -f "$incoming" - say " wrote $path" -} - -# Writes stdin to a file, replacing it. Used only for files this script owns. -write_always() { - local path="$1" - if (( DRY_RUN )); then - say " would write $path" - cat >/dev/null - else - mkdir -p "$(dirname "$path")" - cat > "$path" - say " wrote $path" - fi -} - if [[ "$(uname -s)" != "Linux" ]]; then say "This installer is for Linux only." >&2 exit 1 diff --git a/scripts/linux/uninstall.sh b/scripts/linux/uninstall.sh index fe019ce27..f968a030d 100755 --- a/scripts/linux/uninstall.sh +++ b/scripts/linux/uninstall.sh @@ -22,19 +22,6 @@ esac # shellcheck source=scripts/linux/common.sh source "$SCRIPT_DIR/common.sh" -# Deletes a file this script owns. -remove_file() { - local path="$1" - if [[ ! -e "$path" ]]; then - say " nothing to remove at $path" - elif (( DRY_RUN )); then - say " would delete $path" - else - rm -f "$path" - say " deleted $path" - fi -} - step "Removing the sy Codex profile" remove_file "$CODEX_PROFILE_CONFIG" diff --git a/scripts/macos/common.sh b/scripts/macos/common.sh new file mode 100644 index 000000000..24c224451 --- /dev/null +++ b/scripts/macos/common.sh @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Paths, markers, and helpers shared by install.sh and uninstall.sh. +# The markers must match between the two, which is why they live here. + +# shellcheck source=scripts/common.sh +source "$(dirname "${BASH_SOURCE[0]}")/../common.sh" + +SERVER_LABEL="com.nvidia.switchyard.server" +LAUNCH_AGENTS="$HOME/Library/LaunchAgents" +CODEX_CONFIG="$CODEX_DIR/config.toml" +# Codex reads `--profile sy` from its own file next to config.toml. A +# [profiles.sy] table in config.toml is rejected outright as legacy config. +# Codex.app ignores profiles entirely, so it gets a whole config.toml with the +# routing inlined. Keep the generated file beside the original for reference. +CODEX_SWITCHYARD_CONFIG="$CODEX_DIR/config.sy.toml" +CODEX_DIRECT_CONFIG="$CODEX_DIR/config.toml.direct" +PROFILE_START="# >>> switchyard sy profile >>>" +PROFILE_END="# <<< switchyard sy profile <<<" + +codex_config_uses_switchyard() { + local path="$1" + [[ -f "$path" ]] || return 1 + grep -Fq '[model_providers.sy]' "$path" +} diff --git a/scripts/macos/install.sh b/scripts/macos/install.sh new file mode 100755 index 000000000..eb2a3bbf3 --- /dev/null +++ b/scripts/macos/install.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Installs the Switchyard background server as a per-user LaunchAgent and sets +# up a `sy` Codex profile. +# +# Reinstalling keeps the original Codex config backup and regenerates the +# routed config from it. +# Run with --dry-run to print what would happen. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +DRY_RUN=0 +[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1 + +# shellcheck source=scripts/macos/common.sh +source "$SCRIPT_DIR/common.sh" + +# Builds a whole Codex config that routes through Switchyard, derived from the +# original config.toml. Codex.app cannot use the sy profile. +write_codex_switchyard_config() { + local source="$1" target="$2" + if [[ ! -f "$source" ]]; then + say " no $source; using an empty baseline" + fi + if (( DRY_RUN )); then + say " would write $target" + return + fi + + local body + body="$(mktemp)" + # Drop what this file replaces: the top-level model selection, any + # model_provider, and an earlier copy of the provider table. + if [[ -f "$source" ]]; then + awk ' + BEGIN { preamble = 1; skip_table = 0 } + /^[[:space:]]*\[/ { + preamble = 0 + header = $0 + sub(/[[:space:]]*#.*/, "", header) + skip_table = (header ~ /^[[:space:]]*\[model_providers\.sy\][[:space:]]*$/) + } + preamble && /^[[:space:]]*(model|model_provider)[[:space:]]*=/ { next } + !skip_table { print } + ' "$source" > "$body" + else + : > "$body" + fi + + { + echo "# Generated by the Switchyard installer from the existing Codex config." + echo "model = \"switchyard\"" + echo "model_provider = \"sy\"" + cat "$body" + echo + echo "[model_providers.sy]" + echo "name = \"Switchyard\"" + echo "base_url = \"http://127.0.0.1:$SY_PORT/v1\"" + echo "wire_api = \"responses\"" + echo "requires_openai_auth = true" + } > "$target" + rm -f "$body" + say " wrote $target" +} + +xml_escape_text() { + printf '%s' "$1" | sed \ + -e 's/&/\&/g' \ + -e 's//\>/g' +} + +if [[ "$(uname -s)" != "Darwin" ]]; then + say "This installer is for macOS only." >&2 + exit 1 +fi + +step "Building release binaries" +run cargo build --release --manifest-path "$REPO_ROOT/Cargo.toml" \ + -p switchyard-server + +step "Installing binaries into $SY_HOME/bin" +run mkdir -p "$SY_HOME/bin" "$SY_HOME/logs" +run install -m 755 "$REPO_ROOT/target/release/switchyard-server" \ + "$SY_HOME/bin/switchyard-server" + +step "Writing server config" +# Keep macOS and Linux on the same routing defaults. +write_once "$SY_HOME/composite.toml" < "$REPO_ROOT/scripts/config/composite.toml" + +step "Validating the server config" +if (( DRY_RUN )); then + say " would run: $SY_HOME/bin/switchyard-server --config $SY_HOME/composite.toml --dry-run" +else + "$SY_HOME/bin/switchyard-server" --config "$SY_HOME/composite.toml" --dry-run +fi + +step "Writing LaunchAgents" +XML_SY_HOME="$(xml_escape_text "$SY_HOME")" +write_always "$LAUNCH_AGENTS/$SERVER_LABEL.plist" < + + + + Label + $SERVER_LABEL + ProgramArguments + + $XML_SY_HOME/bin/switchyard-server + --config + $XML_SY_HOME/composite.toml + --host + 127.0.0.1 + --port + $SY_PORT + --routing-log-file + $XML_SY_HOME/routing.jsonl + + RunAtLoad + + KeepAlive + + ProcessType + Background + StandardOutPath + $XML_SY_HOME/logs/server.log + StandardErrorPath + $XML_SY_HOME/logs/server.err.log + EnvironmentVariables + + RUST_LOG + info + + + +EOF + +step "Loading LaunchAgents" +if (( DRY_RUN )); then + say " would reload gui/$UID/$SERVER_LABEL" +else + launchctl bootout "gui/$UID/$SERVER_LABEL" 2>/dev/null || true + # bootout returns before the job leaves the domain, and bootstrapping a + # service that is still shutting down fails with an I/O error. + for _ in $(seq 50); do + launchctl print "gui/$UID/$SERVER_LABEL" >/dev/null 2>&1 || break + sleep 0.1 + done + launchctl bootstrap "gui/$UID" "$LAUNCH_AGENTS/$SERVER_LABEL.plist" + say " loaded $SERVER_LABEL" +fi + +step "Adding the sy Codex profile" +# `codex --profile sy` reads ~/.codex/sy.config.toml. A [profiles.sy] table in +# config.toml is legacy config that Codex now refuses to start with, so an +# earlier install of this script has to be cleaned up first. +strip_block "$CODEX_CONFIG" "$PROFILE_START" "$PROFILE_END" "the legacy sy profile" || true + +# This profile only changes which router answers. Approval and sandbox +# settings are deliberately left out, so the profile cannot loosen how Codex +# asks before it acts. Set those yourself if you want them. +sed "s/@SY_PORT@/$SY_PORT/g" "$REPO_ROOT/scripts/config/codex.sy.toml" | + write_with_backup "$CODEX_PROFILE_CONFIG" + +step "Building a swap-in config for Codex.app" +# Keep the original config as the source for every generated routed config. +CONFIG_SOURCE="$CODEX_CONFIG" +if [[ -f "$CODEX_DIRECT_CONFIG" ]]; then + CONFIG_SOURCE="$CODEX_DIRECT_CONFIG" +elif [[ -f "$CODEX_CONFIG" ]]; then + if codex_config_uses_switchyard "$CODEX_CONFIG"; then + say " no original config backup found; preserving the current routed config" + elif (( DRY_RUN )); then + say " would snapshot config.toml to $CODEX_DIRECT_CONFIG" + CONFIG_SOURCE="$CODEX_DIRECT_CONFIG" + else + cp "$CODEX_CONFIG" "$CODEX_DIRECT_CONFIG" + say " snapshotted config.toml to $CODEX_DIRECT_CONFIG" + CONFIG_SOURCE="$CODEX_DIRECT_CONFIG" + fi +else + if (( DRY_RUN )); then + say " would create an empty direct-config baseline at $CODEX_DIRECT_CONFIG" + CONFIG_SOURCE="$CODEX_DIRECT_CONFIG" + else + mkdir -p "$(dirname "$CODEX_DIRECT_CONFIG")" + : > "$CODEX_DIRECT_CONFIG" + say " created an empty direct-config baseline at $CODEX_DIRECT_CONFIG" + CONFIG_SOURCE="$CODEX_DIRECT_CONFIG" + fi +fi +write_codex_switchyard_config "$CONFIG_SOURCE" "$CODEX_SWITCHYARD_CONFIG" +write_with_backup "$CODEX_CONFIG" < "$CODEX_SWITCHYARD_CONFIG" + +step "Done" +say "Server: http://127.0.0.1:$SY_PORT (logs in $SY_HOME/logs)" +say "Use it with: codex -p sy (requires Codex CLI 0.134.0 or newer)" +say "" +if [[ -f "$CODEX_DIRECT_CONFIG" ]]; then + say "Codex.app is routed. Its original config is backed up at:" + say " $CODEX_DIRECT_CONFIG" +else + say "Codex.app is routed, but no original config backup was available." +fi +say "Restart Codex.app to load the updated config." diff --git a/scripts/macos/uninstall.sh b/scripts/macos/uninstall.sh new file mode 100755 index 000000000..dda11e2e9 --- /dev/null +++ b/scripts/macos/uninstall.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Removes what install.sh added: the LaunchAgent, the `sy` Codex profile, +# and the codex alias. Your config, routing log, and binaries stay put; the +# paths are printed so you can delete them yourself. +# +# Run with --dry-run to print what would happen. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +DRY_RUN=0 +[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1 + +# shellcheck source=scripts/macos/common.sh +source "$SCRIPT_DIR/common.sh" + +step "Unloading LaunchAgents" +if (( DRY_RUN )); then + say " would unload gui/$UID/$SERVER_LABEL and delete its plist" +else + launchctl bootout "gui/$UID/$SERVER_LABEL" 2>/dev/null || true + rm -f "$LAUNCH_AGENTS/$SERVER_LABEL.plist" + say " unloaded $SERVER_LABEL" +fi + +step "Removing the sy Codex profile" +remove_file "$CODEX_PROFILE_CONFIG" +remove_file "$CODEX_SWITCHYARD_CONFIG" +# Older installs of this script put the profile in config.toml instead. +strip_block "$CODEX_CONFIG" "$PROFILE_START" "$PROFILE_END" "the legacy sy profile" || + say " no legacy profile in $CODEX_CONFIG" + +step "Unrouting Codex.app" +if [[ -f "$CODEX_DIRECT_CONFIG" ]]; then + if (( DRY_RUN )); then + say " would preserve $CODEX_CONFIG before restoring $CODEX_DIRECT_CONFIG" + else + backup="" + if [[ -f "$CODEX_CONFIG" ]]; then + backup="$(mktemp "$CODEX_CONFIG.switchyard-current.XXXXXX")" + cp "$CODEX_CONFIG" "$backup" + fi + cp "$CODEX_DIRECT_CONFIG" "$CODEX_CONFIG" + rm -f "$CODEX_DIRECT_CONFIG" + say " restored the original config.toml${backup:+; preserved the current file at $backup}" + fi +elif codex_config_uses_switchyard "$CODEX_CONFIG"; then + say " config.toml is routed but $CODEX_DIRECT_CONFIG is missing; edit it by hand" +else + remove_file "$CODEX_DIRECT_CONFIG" +fi + +step "Removing the codex alias" +for rc in "$HOME/.zshrc" "$HOME/.bashrc"; do + strip_block "$rc" "$ALIAS_START" "$ALIAS_END" "the codex alias" || + say " no alias in $rc" +done + +step "Done" +say "Left in place, delete them if you want:" +say " $SY_HOME (binaries, config, routing log, logs)" +say " $CODEX_CONFIG.switchyard-current.* (configs preserved during restore)" +say " $CODEX_PROFILE_CONFIG.switchyard-backup.* (profile backups)" diff --git a/tests/test_linux_install.py b/tests/test_linux_install.py index 33b029beb..7d070c8e3 100644 --- a/tests/test_linux_install.py +++ b/tests/test_linux_install.py @@ -18,6 +18,7 @@ def setup(tmp_path): scripts = tmp_path / "repo" / "scripts" / "linux" shutil.copytree(REPO / "scripts" / "linux", scripts) + shutil.copy(REPO / "scripts" / "common.sh", scripts.parent / "common.sh") shutil.copytree(REPO / "scripts" / "config", scripts.parent / "config") home = tmp_path / "home" home.mkdir() diff --git a/tests/test_macos_install.py b/tests/test_macos_install.py new file mode 100644 index 000000000..0be77d381 --- /dev/null +++ b/tests/test_macos_install.py @@ -0,0 +1,171 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import os +import shutil +import subprocess +import xml.etree.ElementTree as ET +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[1] + + +@pytest.fixture +def setup(tmp_path): + scripts = tmp_path / "repo" / "scripts" / "macos" + shutil.copytree(REPO / "scripts" / "macos", scripts) + shutil.copy(REPO / "scripts" / "common.sh", scripts.parent / "common.sh") + shutil.copytree(REPO / "scripts" / "config", scripts.parent / "config") + home = tmp_path / "home" + home.mkdir() + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + switchyard_home = home / "Switchyard & Routing " + env = { + **os.environ, + "HOME": str(home), + "SY_HOME": str(switchyard_home), + "CODEX_HOME": str(home / ".codex"), + "TMPDIR": str(tmp_path), + "PATH": f"{bin_dir}:/usr/bin:/bin", + } + stubs = { + "cargo": "exit 0\n", + "uname": "echo Darwin\n", + "install": 'printf "#!/bin/sh\\nexit 0\\n" > "$4"\nchmod +x "$4"\n', + "launchctl": '[[ "$1" != print ]]\n', + } + for name, body in stubs.items(): + stub = bin_dir / name + stub.write_text("#!/bin/bash\nset -eu\n" + body) + stub.chmod(0o755) + return scripts, home, switchyard_home, env + + +def run(setup, script): + scripts, _, _, env = setup + return subprocess.run( + ["bash", str(scripts / script)], + env=env, + capture_output=True, + text=True, + timeout=10, + ) + + +def read_config(path): + return path.read_text() + + +def test_install_escapes_switchyard_path_in_launch_agent(setup): + _, home, switchyard_home, _ = setup + result = run(setup, "install.sh") + assert result.returncode == 0, result.stderr + plist = home / "Library" / "LaunchAgents" / "com.nvidia.switchyard.server.plist" + root = ET.parse(plist).getroot() + entries = list(root.find("dict")) + values = {entries[index].text: entries[index + 1] for index in range(0, len(entries), 2)} + program_arguments = [element.text for element in values["ProgramArguments"]] + assert program_arguments[0] == str(switchyard_home / "bin" / "switchyard-server") + assert program_arguments[2] == str(switchyard_home / "composite.toml") + assert program_arguments[8] == str(switchyard_home / "routing.jsonl") + assert values["StandardOutPath"].text == str(switchyard_home / "logs" / "server.log") + assert values["StandardErrorPath"].text == str(switchyard_home / "logs" / "server.err.log") + + +def test_missing_codex_config_creates_routed_config_and_empty_backup(setup): + _, home, _, _ = setup + result = run(setup, "install.sh") + assert result.returncode == 0, result.stderr + codex = home / ".codex" + assert 'model_provider = "sy"' in read_config(codex / "config.sy.toml") + assert read_config(codex / "config.toml") == read_config(codex / "config.sy.toml") + assert (codex / "config.toml.direct").read_text() == "" + + +def test_install_prints_profile_usage_without_editing_shell_files(setup): + _, home, _, _ = setup + zshrc = home / ".zshrc" + bashrc = home / ".bashrc" + zshrc.write_text("zsh settings\n") + bashrc.write_text("bash settings\n") + + result = run(setup, "install.sh") + + assert result.returncode == 0, result.stderr + assert "Use it with: codex -p sy" in result.stdout + assert zshrc.read_text() == "zsh settings\n" + assert bashrc.read_text() == "bash settings\n" + + +def test_provider_table_with_comment_is_replaced_and_shared_template_is_used(setup): + _, home, switchyard_home, env = setup + codex = Path(env["CODEX_HOME"]) + codex.mkdir() + config = codex / "config.toml" + original = '''theme = "dark" + +[model_providers.sy] # old provider +name = "Old" +base_url = "http://old" + +[other] +model_provider = "sy" +''' + config.write_text(original) + (codex / "config.toml.direct").write_text(original) + result = run(setup, "install.sh") + assert result.returncode == 0, result.stderr + generated = read_config(codex / "config.sy.toml") + assert generated.count("[model_providers.sy]") == 1 + assert 'name = "Old"' not in generated + assert '[other]\nmodel_provider = "sy"' in generated + assert read_config(config) == generated + assert read_config(switchyard_home / "composite.toml") == read_config( + REPO / "scripts" / "config" / "composite.toml" + ) + assert read_config(codex / "config.toml.direct") == original + backups = list(codex.glob("config.toml.switchyard-backup.*")) + assert len(backups) == 1 + assert backups[0].read_text() == original + + +def test_provider_table_marker_keeps_existing_snapshot(setup): + _, home, _, env = setup + codex = Path(env["CODEX_HOME"]) + codex.mkdir() + (codex / "config.toml").write_text('[model_providers.sy]\nname = "Switchyard"\n') + snapshot = codex / "config.toml.direct" + snapshot.write_text("original direct config\n") + result = run(setup, "install.sh") + assert result.returncode == 0, result.stderr + assert snapshot.read_text() == "original direct config\n" + assert (codex / "config.sy.toml").is_file() + + +def test_provider_setting_without_table_gets_snapshotted(setup): + _, _, _, env = setup + codex = Path(env["CODEX_HOME"]) + codex.mkdir() + original = 'model_provider = "sy"\n' + (codex / "config.toml").write_text(original) + result = run(setup, "install.sh") + assert result.returncode == 0, result.stderr + assert (codex / "config.toml.direct").read_text() == original + + +def test_uninstall_preserves_routed_config_before_restoring_snapshot(setup): + _, home, _, env = setup + codex = Path(env["CODEX_HOME"]) + codex.mkdir() + current = '[model_providers.sy]\nname = "Switchyard"\nuser_setting = "keep me"\n' + (codex / "config.toml").write_text(current) + (codex / "config.toml.direct").write_text("model = \"original\"\n") + result = run(setup, "uninstall.sh") + assert result.returncode == 0, result.stderr + assert (codex / "config.toml").read_text() == 'model = "original"\n' + backups = list(codex.glob("config.toml.switchyard-current.*")) + assert len(backups) == 1 + assert backups[0].read_text() == current