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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/switchyard-menubar/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ serde_json.workspace = true
tempfile = "3"
toml = "0.9"
toml_edit = "0.25"
url = "2"
uuid.workspace = true

[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
Expand Down
96 changes: 90 additions & 6 deletions crates/switchyard-menubar/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# switchyard-menubar

A macOS menu bar companion for a Switchyard server running in the background.
The macOS app shows usage, installs coding-tool settings, and opens local
worktree sessions. Its menu bar shows server status and estimated savings.

The server does the routing. This process reads what the server wrote and
edits the server config only when you ask it to, so quitting it never affects
Expand All @@ -22,10 +23,11 @@ make install-macos # or install-macos-dry-run to see the steps first
make uninstall-macos
```

That builds `switchyard-server` and `switchyard-menubar` into
`~/.switchyard/bin`, writes a server config and menu bar settings, loads two
LaunchAgents, and writes a `sy` Codex profile. Use the CLI profile with
`codex -p sy`.
`make install-macos` builds `switchyard-server` and `switchyard-menubar` into
`~/.switchyard/bin` and installs `~/Applications/Switchyard.app`. It writes a
server config and menu bar settings, loads two LaunchAgents, and writes a `sy`
Codex profile. Use the CLI profile with `codex -p sy`. Open Switchyard from
Finder or Spotlight to see Usage, or choose **Install…** to set up a coding tool.

Your server config and menu bar settings are never overwritten once they
exist, so edits survive a reinstall. Logs are in `~/.switchyard/logs/`.
Expand Down Expand Up @@ -318,6 +320,88 @@ Reinstalling backs up the profile before replacing it if its contents change.
The profile sets `model`, `model_provider`, and the Switchyard provider details.
It leaves `approval_policy` and `sandbox_mode` to your existing Codex settings.

Codex.app cannot use the profile. The installer leaves its config unchanged.
Codex.app cannot use the profile. `make install-macos` leaves its
`config.toml` unchanged. To change the defaults used by Codex.app and Codex CLI,
choose **Codex app and CLI defaults** in the app’s **Install…** view.
For older installs that replaced `config.toml`, `make uninstall-macos` restores
`config.toml.direct` and keeps a copy of the active config before replacing it.

## Update the app from source

**Update from source…** opens Terminal and runs `scripts/macos/install.sh` from
the checkout used for the current install. Keep that checkout and a Rust
toolchain available. Update the checkout first to choose the code you want to
build. Routing settings and prices stay saved. The installer signs the app ad
hoc for local use; the app has no Developer ID signature or notarization.

## Install coding-tool settings

**Install…** shows where Codex, Claude Code, and Pi are installed, their user
settings, and their configured model. Pick a public route on the local HTTP
server and click **Install / refresh**. Codex CLI gets `sy.config.toml` and uses
`codex -p sy`. The Codex app option changes the defaults in `config.toml` for
both the app and CLI. Claude Code gets `settings.json` environment settings. Pi gets a custom provider in `models.json` and defaults in
`settings.json`. Unrelated settings remain. The original files are backed up
beside each file as `<filename>.switchyard-original`. If a file did not exist,
`<filename>.switchyard-original-missing` records its absence. **Restore original**
keeps a copy of the current file, then restores the backup or removes the file
that was originally absent.

The app checks all selected settings files before replacing any of them. Each
replacement is atomic. If a later replacement fails, the app tries to restore
earlier files. A crash can leave Pi’s two files out of sync, and rollback can
fail. Another process running as the same user can also change a file after the
app checks it. The original backups remain available for manual recovery.

Restart the coding tool after changing its defaults. Project settings, shell
variables, and command-line options can override them.

Subscription routes forward the caller's saved login to its own provider.
Choose Codex for a ChatGPT route or Claude Code for an Anthropic route. Pi's
custom provider uses routes whose API credentials the server owns. Configure
those credentials on the server before installing its route. The app never
reads or copies OAuth tokens, switches accounts in running sessions, or combines
subscription quotas. Claude settings with an explicit API key or bearer token
must be cleared before installing a subscription route. Shell overrides still
need to be checked with Claude's `/status`.

## View session usage

Usage lists the actual upstream model, public route, timestamp, input, cached,
and output tokens for each completed model call. Select a recorded session ID
to filter the view. A recorded turn ID groups related calls; a turn may contain
several model calls. Older records and tools that send no IDs say **Not recorded**.
The viewer reads the last 8 MiB of the log plus one byte to check whether the
first record is complete. It keeps at most 5,000 complete records and excludes
partial records. It labels a limited view and counts unreadable records.
Classifier calls appear as routing overhead. It displays neither prompts nor responses.
Observed tokens do not report provider quota, reset windows, or authoritative
billing. Refresh reads the log again.

## Start sessions with separate logins and worktrees

Codex CLI and Claude Code support named accounts. Enter a name and click
**Add account…** to open the tool’s own login command in Terminal. Complete the
login, click **Refresh**, and select the account. Switchyard keeps these account
directories under `~/.switchyard/accounts`; the coding tool owns their login
files. The selected account determines where **Install / refresh** saves settings
and which login a new session uses. Existing sessions keep their login.

To run several tasks with one coding tool, enter a Git project path under
Install and click **New worktree session**. Each session starts from that
checkout's HEAD on a new branch in `~/.switchyard/worktrees` and opens in Terminal.
Uncommitted files stay in the original checkout. Each launched session has its
own model settings; launching another session does not change user defaults.
Codex and Claude launches send a session ID for usage correlation. Pi stores
its session under the private agent directory beside the worktree; its requests
need an integration that sends session and turn IDs to appear as a correlated
session. Otherwise their usage is still listed under **All sessions**.
Use `git worktree list` and `git worktree remove` to manage the checkouts after
saving or discarding changes. Successful launches keep their Git branches and
the private settings directories beside the worktrees until you remove them. Named
account directories also remain until you remove them. If launch setup fails,
the app tries to remove the worktree, branch, and private settings it created;
cleanup can fail.

The app does not manage task dependencies, resume history, remote workspaces,
or review comments.
204 changes: 204 additions & 0 deletions crates/switchyard-menubar/src/accounts.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! Named login directories use the coding tool's own authentication commands.

use crate::harness::{self, Harness};
use std::io::Write;
use std::path::{Path, PathBuf};

// PendingAccount removes only an empty directory if login setup fails.
// The coding tool may already have saved login files, which must stay.
struct PendingAccount(Option<PathBuf>);

impl Drop for PendingAccount {
fn drop(&mut self) {
if let Some(path) = &self.0 {
let _ = std::fs::remove_dir(path);
}
}
}

fn root(tool: Harness) -> Result<PathBuf, String> {
let name = match tool {
Harness::CodexCli => "codex",
Harness::Claude => "claude",
_ => return Err("Named accounts are available for Codex CLI and Claude Code.".into()),
};
let home = crate::config::expand_home(Path::new("~"));
let mut path = home;
for component in [".switchyard", "accounts", name] {
path.push(component);
if std::fs::symlink_metadata(&path).is_ok_and(|m| m.file_type().is_symlink()) {
return Err(format!(
"{} is a symlink. Edit its target yourself.",
path.display()
));
}
}
Ok(path)
}

pub fn validate(tool: Harness, path: &Path) -> Result<(), String> {
let root = root(tool)?;
if path.parent() != Some(root.as_path())
|| !std::fs::symlink_metadata(path).is_ok_and(|m| m.is_dir() && !m.file_type().is_symlink())
{
return Err("Choose a supported coding tool.".into());
}
Ok(())
}

pub fn list(tool: Harness) -> Vec<(String, PathBuf)> {
let Ok(root) = root(tool) else {
return Vec::new();
};
let Ok(entries) = std::fs::read_dir(root) else {
return Vec::new();
};
let mut accounts: Vec<_> = entries
.flatten()
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
.filter_map(|e| {
e.file_name()
.into_string()
.ok()
.map(|name| (name, e.path()))
})
.collect();
accounts.sort_by(|a, b| a.0.cmp(&b.0));
accounts
}

pub fn config_paths(tool: Harness, account: Option<&Path>) -> Vec<PathBuf> {
let Some(account) = account else {
return harness::paths(tool);
};
match tool {
Harness::CodexCli => vec![account.join("sy.config.toml")],
Harness::Claude => vec![account.join("settings.json")],
_ => harness::paths(tool),
}
}

pub fn add(tool: Harness, name: &str) -> Result<String, String> {
if name.is_empty()
|| name.len() > 64
|| !name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
{
return Err("Account names need 1–64 letters, numbers, hyphens, or underscores.".into());
}
let root = root(tool)?;
let binary = harness::binary(tool).ok_or("Install the selected coding tool first.")?;
std::fs::create_dir_all(&root).map_err(|e| e.to_string())?;
let path = root.join(name);
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
std::fs::DirBuilder::new().mode(0o700).create(&path).map_err(|e|format!("Create account: {e}. Pick an existing account after Refresh, or choose a new name."))?;
}
#[cfg(not(unix))]
std::fs::create_dir(&path).map_err(|e| e.to_string())?;
let mut pending = PendingAccount(Some(path.clone()));
let variable = match tool {
Harness::CodexCli => "CODEX_HOME",
Harness::Claude => "CLAUDE_CONFIG_DIR",
_ => return Err("Choose a supported coding tool.".into()),
};
let login = match tool {
Harness::CodexCli => "login",
_ => "auth login",
};
let mut script = tempfile::Builder::new()
.prefix("switchyard-login-")
.suffix(".command")
.tempfile()
.map_err(|e| e.to_string())?;
writeln!(script,"#!/bin/bash\nrm -- \"$0\"\nexport PATH=\"/opt/homebrew/bin:/usr/local/bin:$PATH\"\nexport {variable}={}\n{} {login}",crate::sessions::quote(&path.display().to_string()),crate::sessions::quote(&binary.display().to_string())).map_err(|e|e.to_string())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
script
.as_file()
.set_permissions(std::fs::Permissions::from_mode(0o700))
.map_err(|e| e.to_string())?;
}
crate::server::command("open", &[&script.path().display().to_string()])?;
pending.0 = None;
let _ = script.keep().map_err(|e| e.to_string())?;
Ok(format!(
"Opened the coding tool's login in Terminal for account {name}. Complete login, click Refresh, and select this account. Switchyard does not read the login tokens. The selected account applies to new sessions only. Account directory: {}",
path.display()
))
}

#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unsafe_names_fail_before_any_side_effect() {
for name in ["", "..", "a/b", "$(touch bad)", "a\nb"] {
assert!(add(Harness::CodexCli, name).is_err());
}
assert!(add(Harness::Pi, "valid").is_err());
}
#[test]
fn selected_account_changes_only_the_owned_config_destination() {
let account = Path::new("/tmp/named-account");
assert_eq!(
config_paths(Harness::CodexCli, Some(account)),
vec![account.join("sy.config.toml")]
);
assert_eq!(
config_paths(Harness::Claude, Some(account)),
vec![account.join("settings.json")]
);
}
#[test]
fn a_login_setup_failure_removes_only_its_empty_directory() {
let root = tempfile::tempdir().expect("directory");
let path = root.path().join("account");
std::fs::create_dir(&path).expect("account");
drop(PendingAccount(Some(path.clone())));
assert!(!path.exists());
std::fs::create_dir(&path).expect("account");
std::fs::write(path.join("auth.json"), "private").expect("auth");
drop(PendingAccount(Some(path.clone())));
assert!(path.join("auth.json").is_file());
}
// The subprocess isolates HOME so parallel tests keep their own settings.
#[cfg(unix)]
#[test]
fn symlinked_account_roots_are_not_listed_or_used_for_login() {
if std::env::var_os("SWITCHYARD_ACCOUNT_FIXTURE").is_some() {
assert!(list(Harness::CodexCli).is_empty());
assert!(add(Harness::CodexCli, "new").is_err());
let path = crate::config::expand_home(Path::new("~/.switchyard/accounts/codex/named"));
assert!(validate(Harness::CodexCli, &path).is_err());
return;
}
let dir = tempfile::tempdir().expect("fixture");
let home = dir.path().join("home");
let target = dir.path().join("target");
std::fs::create_dir(&home).expect("home");
std::fs::create_dir_all(target.join("accounts/codex/named")).expect("accounts");
std::os::unix::fs::symlink(&target, home.join(".switchyard")).expect("symlink");
let output = std::process::Command::new(std::env::current_exe().expect("runner"))
.args([
"--exact",
"accounts::tests::symlinked_account_roots_are_not_listed_or_used_for_login",
])
.env("SWITCHYARD_ACCOUNT_FIXTURE", "1")
.env("HOME", &home)
.output()
.expect("child");
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stdout)
);
assert!(!target.join("accounts/codex/new").exists());
}
}
Loading
Loading