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

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ owo-colors = "4"
tar = "0.4"
zip = { version = "8", default-features = false, features = ["deflate"] }
libc = "0.2"
rpassword = "7"

[target.'cfg(windows)'.dependencies]
windows-sys = { version = "=0.61.2", features = [
Expand Down
48 changes: 48 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,51 @@ pub enum DaemonCommand {
Uninstall,
}

#[derive(Debug, Clone, Subcommand)]
pub enum ProviderCommand {
/// Add a custom API provider (e.g. OpenRouter) for launching Codex with a third-party model
#[command(
after_help = "The API key is read from a hidden prompt (or stdin with --api-key-stdin), never from the command line.\n\nExample:\n codex-switch provider add openrouter \\\n --base-url https://openrouter.ai/api/v1 \\\n --model openai/gpt-5.3-codex"
)]
Add {
/// Provider alias (codex-switch name)
alias: String,
/// API base URL, e.g. https://openrouter.ai/api/v1
#[arg(long)]
base_url: String,
/// Default model id (for OpenRouter, the full slug incl. provider prefix)
#[arg(long)]
model: String,
/// Human-readable provider name (defaults to the alias)
#[arg(long)]
name: Option<String>,
/// Environment variable Codex reads the key from (defaults to a codex-switch-owned name)
#[arg(long)]
env_key: Option<String>,
/// Codex wire protocol (current Codex only supports "responses")
#[arg(long, default_value = "responses")]
wire_api: String,
/// Read the API key from stdin instead of an interactive hidden prompt
#[arg(long)]
api_key_stdin: bool,
},
/// List saved custom providers
List,
/// Show one provider's details (API key redacted)
Show {
/// Provider alias
alias: String,
},
/// Remove a custom provider and its stored key
Remove {
/// Provider alias
alias: String,
/// Skip confirmation prompt
#[arg(long, short)]
yes: bool,
},
}

#[derive(Parser)]
#[command(
name = "codex-switch",
Expand Down Expand Up @@ -171,6 +216,9 @@ pub enum Commands {
Tui,
/// Open the ~/.codex-switch directory in the system file manager
Open,
/// Manage custom API providers (OpenRouter, etc.) for launching Codex with a third-party model
#[command(subcommand)]
Provider(ProviderCommand),
/// Background daemon (Beta) for automatic account switching
#[command(subcommand)]
Daemon(DaemonCommand),
Expand Down
2 changes: 2 additions & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ mod launch;
mod login;
mod misc;
mod profile;
mod provider;
mod render;
mod update;

Expand All @@ -11,5 +12,6 @@ pub(crate) use launch::launch_cmd;
pub(crate) use login::login_cmd;
pub(crate) use misc::{open_cmd, reset_card_cmd, warmup_cmd};
pub(crate) use profile::{delete_cmd, list_cmd, rename_cmd, use_cmd};
pub(crate) use provider::provider_cmd;
pub(crate) use render::confirm;
pub(crate) use update::self_update_cmd;
203 changes: 203 additions & 0 deletions src/commands/provider.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
use std::io::{IsTerminal, Read};

use anyhow::{Context, Result};

use super::render::confirm_default_no;
use crate::cli::ProviderCommand;
use crate::output::{JsonOk, print_json, user_println};
use crate::provider::{self, ProviderProfile};

pub(crate) fn provider_cmd(cmd: ProviderCommand, json: bool) -> Result<()> {
match cmd {
ProviderCommand::Add {
alias,
base_url,
model,
name,
env_key,
wire_api,
api_key_stdin,
} => add(
alias,
base_url,
model,
name,
env_key,
wire_api,
api_key_stdin,
json,
),
ProviderCommand::List => list(json),
ProviderCommand::Show { alias } => show(&alias, json),
ProviderCommand::Remove { alias, yes } => remove(&alias, yes, json),
}
}

#[allow(clippy::too_many_arguments)]
fn add(
alias: String,
base_url: String,
model: String,
name: Option<String>,
env_key: Option<String>,
wire_api: String,
api_key_stdin: bool,
json: bool,
) -> Result<()> {
crate::profile::validate_alias(&alias)?;
if provider::exists(&alias) {
anyhow::bail!("provider '{alias}' already exists");
}
if crate::profile::list_profiles()?.iter().any(|p| p == &alias) {
anyhow::bail!("'{alias}' already names a ChatGPT profile; choose a different alias");
}

let api_key = read_api_key(&alias, api_key_stdin)?;

let profile = ProviderProfile {
provider_id: provider::sanitize_provider_id(&alias),
name: name.unwrap_or_else(|| alias.clone()),
base_url,
env_key: env_key.unwrap_or_else(|| provider::derive_env_key(&alias)),
model,
wire_api,
api_key,
alias: alias.clone(),
};
profile.validate()?;
provider::save(&profile)?;

if json {
print_json(&JsonOk {
ok: true,
alias,
action: "provider-added".into(),
});
} else {
user_println(&format!(
"Added provider '{}' ({}) -> {}",
profile.alias, profile.name, profile.base_url
));
user_println(&format!(
" key stored; Codex reads it from ${} at launch",
profile.env_key
));
}
Ok(())
}

/// Read the API key without exposing it on the command line: from stdin in
/// `--api-key-stdin` mode, otherwise from a hidden interactive prompt. Refuses
/// to run non-interactively without `--api-key-stdin` rather than echoing.
fn read_api_key(alias: &str, stdin_mode: bool) -> Result<String> {
let key = if stdin_mode {
let mut raw = String::new();
std::io::stdin()
.read_to_string(&mut raw)
.context("reading API key from stdin")?;
raw.trim().to_string()
} else if std::io::stdin().is_terminal() {
rpassword::prompt_password(format!("API key for '{alias}': "))
.context("reading API key")?
.trim()
.to_string()
} else {
anyhow::bail!(
"no interactive terminal for a hidden prompt; pass the key on stdin with --api-key-stdin"
);
};
if key.is_empty() {
anyhow::bail!("API key cannot be empty");
}
Ok(key)
}

fn list(json: bool) -> Result<()> {
let aliases = provider::list_providers()?;
if json {
let items: Vec<serde_json::Value> = aliases
.iter()
.filter_map(|alias| provider::load(alias).ok())
.map(|p| {
serde_json::json!({
"alias": p.alias,
"provider_id": p.provider_id,
"name": p.name,
"base_url": p.base_url,
"model": p.model,
"wire_api": p.wire_api,
"env_key": p.env_key,
"has_key": !p.api_key.is_empty(),
})
})
.collect();
print_json(&serde_json::json!({ "providers": items }));
return Ok(());
}
if aliases.is_empty() {
user_println("(no providers)");
return Ok(());
}
for alias in aliases {
match provider::load(&alias) {
Ok(p) => user_println(&format!(
"{} {} {} [{}]",
p.alias, p.name, p.model, p.base_url
)),
Err(e) => user_println(&format!("{alias} (error: {e})")),
}
}
Ok(())
}

fn show(alias: &str, json: bool) -> Result<()> {
let p = provider::load(alias)?;
if json {
print_json(&serde_json::json!({
"alias": p.alias,
"provider_id": p.provider_id,
"name": p.name,
"base_url": p.base_url,
"model": p.model,
"wire_api": p.wire_api,
"env_key": p.env_key,
"key": p.redacted_key(),
}));
return Ok(());
}
user_println(&format!("alias {}", p.alias));
user_println(&format!("name {}", p.name));
user_println(&format!("provider_id {}", p.provider_id));
user_println(&format!("base_url {}", p.base_url));
user_println(&format!("model {}", p.model));
user_println(&format!("wire_api {}", p.wire_api));
user_println(&format!("env_key {}", p.env_key));
user_println(&format!("key {}", p.redacted_key()));
Ok(())
}

fn remove(alias: &str, yes: bool, json: bool) -> Result<()> {
if !provider::exists(alias) {
anyhow::bail!("provider '{alias}' not found");
}
if !yes {
if json || !std::io::stdin().is_terminal() {
anyhow::bail!("confirmation required; rerun with --yes to remove provider '{alias}'");
}
if !confirm_default_no(&format!("Remove provider '{alias}'? [y/N] ")) {
user_println("Removal cancelled.");
return Ok(());
}
}
provider::remove(alias)?;
if json {
print_json(&JsonOk {
ok: true,
alias: alias.to_string(),
action: "provider-removed".into(),
});
} else {
user_println(&format!("Removed provider '{alias}'"));
}
Ok(())
}
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ mod login;
mod output;
pub mod profile;
#[allow(dead_code)]
mod provider;
#[allow(dead_code)]
mod signals;
#[allow(dead_code)]
mod tui;
Expand Down
2 changes: 2 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ mod logging;
mod login;
mod output;
mod profile;
mod provider;
mod signals;
mod tui;
mod update;
Expand Down Expand Up @@ -281,6 +282,7 @@ async fn dispatch(cmd: Commands, json: bool) -> Result<()> {
} => commands::launch_cmd(alias.as_deref(), args, json, consume_card).await?,
Commands::Tui => tui::run_tui().await?,
Commands::Open => commands::open_cmd()?,
Commands::Provider(sub) => commands::provider_cmd(sub, json)?,
Commands::Daemon(sub) => daemon::dispatch(sub, json).await?,
}

Expand Down
Loading