pub mod access;
pub mod bootstrap;
pub(crate) mod client_sessions;
pub(crate) mod codex;
pub mod desktop;
pub mod lifecycle;
pub mod mcp_sync;
pub(crate) mod opencode;
pub(crate) mod opencode2;
pub mod prefs;
pub(crate) mod remote_threads;
pub mod setup;
pub mod skills_sync;
pub mod telemetry;
pub mod tui;
use std::future::Future;
use anyhow::{Context, Result};
use clap::Parser;
use colored::Colorize;
use crate::client::GQLClient;
use crate::commands::code::LaunchArgs;
use crate::config::Configs;
use crate::errors::RailwayError;
use crate::macros::is_stdout_terminal;
use crate::util::progress::create_spinner;
use prefs::AgentPrefs;
use tui::{App, Outcome};
#[derive(Parser)]
#[clap(
args_conflicts_with_subcommands = true,
after_help = r#"Examples:
railway ca # browse and launch agents
railway ca setup # choose defaults and skills
railway ca ssh my-box # open a shell
railway ca sleep my-box # stop compute, keep the disk
railway ca desktop --codex # configure a desktop app
Use railway code for a local Codex/OpenCode client connected to a VM.
Agent flags here launch on the VM. Use --new to create a fresh VM.
Flags override preferences; the linked project overrides the saved project.
Disconnecting leaves the VM running. Sleep stops processes and keeps the disk.
Requires Cloud Agents access.
Guide: https://github.com/railwayapp/cli/blob/master/docs/cloud-agents.md"#
)]
pub struct Args {
#[clap(subcommand)]
command: Option<Command>,
#[clap(flatten)]
launch: LaunchArgs,
}
#[derive(Parser)]
enum Command {
Bootstrap(bootstrap::Args),
Setup(setup::Args),
Desktop(desktop::Args),
Manage,
#[clap(after_help = r#"Examples:
railway ca start --claude # launch Claude Code on the VM
railway ca start --codex --new # launch Codex on a new VM
railway ca start --claude -- exec "explain this codebase"
Launches on the VM directly, without the management interface.
Uses your configured agent and project defaults; --new creates a fresh VM.
Available local credentials are copied to the VM; Claude can mint a setup token.
Disconnecting leaves the VM running. Use railway ca sleep <agent> to stop compute.
Requires Cloud Agents access.
Guide: https://github.com/railwayapp/cli/blob/master/docs/cloud-agents.md"#)]
Start(LaunchArgs),
#[clap(visible_alias = "ls")]
List(lifecycle::ListArgs),
#[clap(visible_alias = "new")]
Create(lifecycle::CreateArgs),
#[clap(visible_alias = "connect")]
Ssh(lifecycle::SshArgs),
Wake(lifecycle::WakeArgs),
Sleep(lifecycle::SleepArgs),
#[clap(visible_alias = "rm")]
Delete(lifecycle::DeleteArgs),
}
pub fn get_dynamic_args(cmd: clap::Command) -> clap::Command {
fn in_vm_help(mut cmd: clap::Command) -> clap::Command {
for id in ["connection_json", "remote_dir", "remote_agent"] {
cmd = cmd.mut_arg(id, |arg| arg.hide(true));
}
cmd.mut_arg("agent_args", |arg| {
arg.help("Arguments to pass to the agent after --")
})
}
in_vm_help(cmd).mut_subcommand("start", in_vm_help)
}
async fn tracked(kind: &'static str, run: impl Future<Output = Result<()>>) -> Result<()> {
let started = std::time::Instant::now();
let result = run.await;
let message = result.as_ref().err().map(|e| format!("{e:#}"));
telemetry::track_lifecycle(kind, started.elapsed(), message.as_deref()).await;
result
}
pub async fn command(args: Args) -> Result<()> {
let interactive = is_stdout_terminal();
if needs_credential(args.command.as_ref(), interactive) {
ensure_logged_in(interactive).await?;
}
match args.command {
Some(Command::Bootstrap(a)) => tracked("bootstrap", bootstrap::command(a)).await,
Some(Command::Setup(a)) => setup::command(a).await,
Some(Command::Desktop(a)) => tracked("desktop", desktop::command(a)).await,
Some(Command::Manage) => browse_into(Some(tui::Screen::Manage)).await,
Some(Command::Start(a)) => crate::commands::code::launch(a).await,
Some(Command::List(a)) => tracked("list", lifecycle::list(a)).await,
Some(Command::Create(a)) => tracked("create", lifecycle::create(a)).await,
Some(Command::Ssh(a)) => lifecycle::ssh(a).await,
Some(Command::Wake(a)) => tracked("wake", lifecycle::wake(a)).await,
Some(Command::Sleep(a)) => tracked("sleep", lifecycle::sleep(a)).await,
Some(Command::Delete(a)) => tracked("delete", lifecycle::delete(a)).await,
None if args.launch.is_bare() && is_stdout_terminal() => browse().await,
None => crate::commands::code::launch_in_cloud(args.launch).await,
}
}
fn needs_credential(command: Option<&Command>, interactive: bool) -> bool {
match command {
Some(Command::Setup(a)) => a.needs_credential(interactive),
_ => true,
}
}
async fn ensure_logged_in(interactive: bool) -> Result<()> {
if Configs::new()?.has_auth_credentials() {
return Ok(());
}
if !interactive {
return Err(RailwayError::Unauthorized.into());
}
println!("{}", "Log in to Railway to continue.".bold());
let result = crate::commands::login::prompt_login().await;
telemetry::track_login_forwarded(result.as_ref().err().map(|e| format!("{e:#}")).as_deref())
.await;
result?;
println!();
Ok(())
}
async fn browse() -> Result<()> {
browse_with(BrowseOpts::default()).await
}
#[derive(Default)]
struct BrowseOpts {
initial_screen: Option<tui::Screen>,
collapsed: bool,
launch: Option<tui::LaunchRequest>,
client_pane: Option<tui::ClientPane>,
}
async fn browse_into(initial_screen: Option<tui::Screen>) -> Result<()> {
browse_with(BrowseOpts {
initial_screen,
..Default::default()
})
.await
}
pub async fn launch_in_pane(args: LaunchArgs) -> Result<()> {
let mut configs = Configs::new()?;
let client = GQLClient::new_authorized(&configs)?;
eprintln!(
"{}",
"Warning: Railway cloud agents are experimental and APIs may change or break during testing."
.yellow()
);
let resolved = {
let ensure_fut = async {
let flag_configs = Configs::new()?;
access::ensure_enabled(&client, &flag_configs).await
};
let resolve_fut = crate::commands::code::resolve_launch(&args, &mut configs, &client);
tokio::pin!(ensure_fut);
tokio::pin!(resolve_fut);
tokio::select! {
enabled = &mut ensure_fut => {
enabled?;
resolve_fut.await?
}
resolved = &mut resolve_fut => {
ensure_fut.await?;
resolved?
}
}
};
let mut args = args;
args.local_name_project = resolved.local_name_project;
let launch = tui::LaunchRequest {
project_id: resolved.project_id,
environment_id: resolved.environment_id,
agent_id: args.agent_id.clone(),
session_name: None,
force_new: args.new,
new_session: false,
harness: resolved.harness.to_string(),
prompt: args.initial_prompt.clone(),
label: resolved.harness.to_string(),
base: Box::new(args),
};
browse_with(BrowseOpts {
initial_screen: Some(tui::Screen::Manage),
collapsed: true,
launch: Some(launch),
client_pane: None,
})
.await
}
async fn browse_with(opts: BrowseOpts) -> Result<()> {
let result = browse_with_inner(opts).await;
crate::commands::ssh::tel::drain_detached(std::time::Duration::from_secs(2)).await;
result
}
pub(crate) async fn launch_client_in_pane(pane: tui::ClientPane) -> Result<()> {
browse_with(BrowseOpts {
initial_screen: Some(tui::Screen::Manage),
collapsed: true,
client_pane: Some(pane),
..Default::default()
})
.await
}
async fn browse_with_inner(opts: BrowseOpts) -> Result<()> {
let BrowseOpts {
initial_screen,
collapsed,
launch,
client_pane,
} = opts;
let mut configs = Configs::new()?;
let client = GQLClient::new_authorized(&configs)?;
let backboard = configs.get_backboard();
let launch_early = launch.as_ref().filter(|req| {
!(req.harness == "claude" && crate::commands::code::claude_needs_local_mint())
});
let (key_ready_tx, key_ready_rx) = tokio::sync::oneshot::channel::<bool>();
let spinner = create_spinner("Loading your projects".to_string());
let (loaded, ssh_key, inflight) = tokio::join!(
async {
tokio::try_join!(
access::ensure_enabled(&client, &configs),
tui::load_tree(&client, &configs),
)
},
async {
let state = check_ssh_key(&client, &configs).await;
let _ = key_ready_tx.send(matches!(state, tui::app::SshKeyState::Ready));
state
},
async {
let req = launch_early?;
match key_ready_rx.await {
Ok(true) => Some(tui::begin_launch_early(req.clone())),
_ => None,
}
},
);
spinner.finish_and_clear();
let (_, tree) = match loaded {
Ok(pair) => pair,
Err(e) => {
if let Some(inflight) = inflight
&& let Some(note) = inflight
.settle_for_abort(std::time::Duration::from_secs(30))
.await
{
eprintln!("{}", note.yellow());
}
return Err(e);
}
};
if tree.is_empty() {
println!(
"No projects with environments you can use. Create one with {} first.",
"railway init".cyan()
);
if let Some(inflight) = inflight
&& let Some(note) = inflight
.settle_for_abort(std::time::Duration::from_secs(30))
.await
{
eprintln!("{}", note.yellow());
}
return Ok(());
}
let home = dirs::home_dir().context("Unable to get home directory")?;
let stored = AgentPrefs::load_in(&home);
let first_run = stored.is_none();
let saved = stored.unwrap_or_default();
let linked = linked_target(&mut configs, &client, &tree).await;
let target = linked.clone().or_else(|| {
saved.default_project.as_ref().map(|project| tui::Target {
project_id: project.project_id.clone(),
project_name: project.project_name.clone(),
environment_id: project.environment_id.clone(),
environment_name: project.environment_name.clone(),
})
});
let default_project_id = linked
.as_ref()
.map(|t| t.project_id.clone())
.or_else(|| saved.default_project.as_ref().map(|p| p.project_id.clone()));
let mut app = App::new(
tree,
target,
saved.agent.as_deref(),
saved.theme.as_deref(),
default_project_id,
!first_run,
);
app.known_environments = configs.code_agent_environments();
app.skills_source = skills_sync::populated_sources(&home)
.first()
.map(|(source, _)| source.slug.to_string());
app.skills_enabled = saved.skills.enabled;
app.hide_tabs = saved.hide_tabs;
app.sidebar_width = saved.sidebar_width;
app.ssh_key = ssh_key;
if let Some(screen) = initial_screen {
app.screen = screen;
} else if first_run {
app.start_wizard(true);
}
if let Some(req) = launch.as_ref()
&& let Some(target) = target_in_tree(&app.tree, &req.environment_id)
{
app.target = Some(target);
}
app.maximized = collapsed;
app.quit_when_done = launch.is_some() || client_pane.is_some();
if let Some(pane) = &client_pane {
app.harness = tui::app::HARNESSES
.iter()
.position(|h| *h == pane.connection.harness())
.unwrap_or(0);
app.target = target_in_tree(&app.tree, &pane.environment_id);
if !app.known_environments.contains(&pane.environment_id) {
app.known_environments.push(pane.environment_id.clone());
}
}
app.autostart_client = client_pane;
if inflight.is_some() {
app.autostart_inflight = inflight;
} else {
app.autostart = launch;
}
let mut pending: Option<tui::LaunchRequest> = None;
loop {
match tui::run(&mut app, client.clone(), backboard.clone(), pending.take()).await? {
Outcome::Quit => {
persist_theme(&home, app.theme.slug);
if let Some(note) = app.exit_note.take() {
println!("{}", note.dimmed());
}
return Ok(());
}
Outcome::OpenShell {
agent_id,
agent_name,
} => {
println!(
"\nOpening an SSH shell on {agent_name}. Exit the shell to return to railway ca."
);
match lifecycle::ssh_shell(agent_id).await {
Ok(0) => {}
Ok(status) => app.toast_error(format!("SSH shell exited with status {status}")),
Err(err) => {
eprintln!("Couldn't open an SSH shell: {err:#}");
pause_for_reentry();
}
}
}
Outcome::FullScreen(req) => {
println!(
"\n{}",
format!("Attaching to {} · {}", req.agent_name, req.session_name).dimmed()
);
let result = crate::commands::ssh::native::run_native_ssh_with_opts(
&req.ssh_target,
None,
req.identity.as_deref(),
Some(crate::commands::ssh::native::DurableResume {
session_name: &req.session_name,
resume_from_last_read: false,
}),
&req.relay_opts,
);
if let Err(err) = result {
eprintln!("{} {err:#}", "Session ended with an error:".red().bold());
}
crate::commands::ssh::native::clear_mouse_tracking();
pause_for_reentry();
}
Outcome::NeedsCredential(req) => {
println!(
"\n{}",
"Claude needs a one-time token for the agent — this opens your browser."
.dimmed()
);
match crate::commands::code::ensure_claude_credential_cached(&req.harness) {
Ok(()) => pending = Some(req),
Err(err) => {
eprintln!("{} {err:#}", "Couldn't mint a credential:".red().bold());
pause_for_reentry();
}
}
}
}
}
}
async fn check_ssh_key(client: &reqwest::Client, configs: &Configs) -> tui::app::SshKeyState {
use crate::controllers::ssh::keys::{find_local_ssh_keys, get_registered_ssh_keys};
use tui::app::{SshKeyOffer, SshKeyState};
let (local, registered) = tokio::join!(
find_local_ssh_keys(),
get_registered_ssh_keys(client, configs, None),
);
let (Ok(local), Ok(registered)) = (local, registered) else {
return SshKeyState::Unknown;
};
if local.is_empty() {
return SshKeyState::NoLocalKeys;
}
if local
.iter()
.any(|l| registered.iter().any(|r| r.fingerprint == l.fingerprint))
{
return SshKeyState::Ready;
}
let key = &local[0];
SshKeyState::NeedsRegistration(SshKeyOffer {
name: key.key_name().to_string(),
fingerprint: key.fingerprint.clone(),
public_key: key.public_key.to_string(),
})
}
fn persist_theme(home: &std::path::Path, slug: &str) {
let mut prefs = AgentPrefs::load_in(home).unwrap_or_default();
if prefs.theme.as_deref() == Some(slug) {
return;
}
prefs.theme = Some(slug.to_string());
let _ = prefs.save_in(home);
}
fn pause_for_reentry() {
use std::io::{BufRead, Write};
print!("\n{}", "Press enter to return to railway ca…".dimmed());
let _ = std::io::stdout().flush();
let mut line = String::new();
let _ = std::io::stdin().lock().read_line(&mut line);
}
fn target_in_tree(tree: &[tui::app::WorkspaceNode], environment_id: &str) -> Option<tui::Target> {
for ws in tree {
for project in &ws.projects {
for env in &project.envs {
if env.id == environment_id {
return Some(tui::Target {
project_id: project.id.clone(),
project_name: project.name.clone(),
environment_id: env.id.clone(),
environment_name: env.name.clone(),
});
}
}
}
}
None
}
async fn linked_target(
configs: &mut Configs,
client: &reqwest::Client,
tree: &[tui::app::WorkspaceNode],
) -> Option<tui::Target> {
let linked = configs.get_linked_project().await.ok()?;
let env_id = linked.environment.clone()?;
let _ = client;
for ws in tree {
for project in &ws.projects {
if project.id != linked.project {
continue;
}
let env = project.envs.iter().find(|e| e.id == env_id)?;
return Some(tui::Target {
project_id: project.id.clone(),
project_name: project.name.clone(),
environment_id: env.id.clone(),
environment_name: env.name.clone(),
});
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn setup_args(argv: &[&str]) -> Command {
Command::Setup(setup::Args::parse_from(
std::iter::once("setup").chain(argv.iter().copied()),
))
}
#[test]
fn launch_paths_need_a_credential() {
assert!(needs_credential(None, true));
assert!(needs_credential(None, false));
assert!(needs_credential(
Some(&Command::Start(LaunchArgs::parse_from(["start"]))),
true
));
}
#[test]
fn local_setup_runs_logged_out() {
assert!(!needs_credential(Some(&setup_args(&["--show"])), true));
assert!(!needs_credential(Some(&setup_args(&["-y"])), true));
assert!(!needs_credential(Some(&setup_args(&[])), false));
}
#[test]
fn interactive_setup_needs_a_credential_for_the_project_picker() {
assert!(needs_credential(Some(&setup_args(&[])), true));
}
}