From e078a3422bfbe0339de598f67939d4cb8d0eae7e Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Sun, 12 Apr 2026 09:33:25 -0700 Subject: [PATCH 1/8] feat: add natural language prompt flag (`railway -p`) Adds a top-level `-p` / `--prompt` flag that sends natural language prompts to the Railway AI chat API via SSE streaming. Supports single-shot mode (`railway -p "message"`) and interactive REPL (`railway -p`). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/mod.rs | 1 + src/commands/prompt.rs | 189 ++++++++++++++++++++++++++++++++++++++++ src/controllers/chat.rs | 180 ++++++++++++++++++++++++++++++++++++++ src/controllers/mod.rs | 1 + src/macros.rs | 9 ++ src/main.rs | 28 ++++-- 6 files changed, 403 insertions(+), 5 deletions(-) create mode 100644 src/commands/prompt.rs create mode 100644 src/controllers/chat.rs diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 957aaba5c..38cd77e5f 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -49,3 +49,4 @@ pub mod volume; pub mod whoami; pub mod check_updates; +pub mod prompt; diff --git a/src/commands/prompt.rs b/src/commands/prompt.rs new file mode 100644 index 000000000..518d36b65 --- /dev/null +++ b/src/commands/prompt.rs @@ -0,0 +1,189 @@ +use std::io::Write; + +use anyhow::Context; +use crate::{ + controllers::{ + chat::{ChatEvent, ChatRequest, build_chat_client, get_chat_url, stream_chat}, + environment::get_matched_environment, + project::get_project, + }, + interact_or, + util::progress::{create_spinner, fail_spinner, success_spinner}, +}; + +use super::*; + +pub struct Args { + pub message: Option, + pub json: bool, + pub thread_id: Option, + pub service: Option, + pub environment: Option, +} + +pub async fn command(args: Args) -> Result<()> { + let configs = Configs::new()?; + let linked_project = configs.get_linked_project().await?; + + let client = GQLClient::new_authorized(&configs)?; + let project = get_project(&client, &configs, linked_project.project.clone()).await?; + + let environment_id = match args.environment.clone() { + Some(env) => get_matched_environment(&project, env)?.id, + None => linked_project.environment_id()?.to_string(), + }; + + let service_id = match args.service { + Some(ref service_arg) => { + let svc = project + .services + .edges + .iter() + .find(|s| s.node.name == *service_arg || s.node.id == *service_arg) + .with_context(|| format!("Service '{service_arg}' not found"))?; + Some(svc.node.id.clone()) + } + None => linked_project.service.clone(), + }; + + let chat_client = build_chat_client(&configs)?; + let url = get_chat_url(&configs); + + if let Some(message) = args.message { + run_single_shot(&chat_client, &url, &ChatRequest { + project_id: linked_project.project.clone(), + environment_id, + message, + thread_id: args.thread_id, + service_id, + }, args.json).await + } else { + run_repl( + &chat_client, + &url, + &linked_project.project, + &environment_id, + service_id.as_deref(), + args.thread_id, + args.json, + ).await + } +} + +async fn run_single_shot( + client: &reqwest::Client, + url: &str, + request: &ChatRequest, + json: bool, +) -> Result<()> { + let mut spinner: Option = None; + + stream_chat(client, url, request, |event| { + if json { + handle_event_json(&event); + } else { + handle_event_human(event, &mut spinner); + } + }).await +} + +async fn run_repl( + client: &reqwest::Client, + url: &str, + project_id: &str, + environment_id: &str, + service_id: Option<&str>, + initial_thread_id: Option, + json: bool, +) -> Result<()> { + interact_or!("Interactive chat requires a terminal. Pass a message as an argument for non-interactive use."); + + println!( + "{}", + "Railway Chat (type 'exit' or Ctrl+C to quit)" + .dimmed() + ); + println!(); + + let mut thread_id = initial_thread_id; + + loop { + let input = inquire::Text::new("You:") + .with_render_config(Configs::get_render_config()) + .prompt(); + + let message = match input { + Ok(msg) if msg.trim().eq_ignore_ascii_case("exit") || msg.trim().eq_ignore_ascii_case("quit") => break, + Ok(msg) if msg.trim().is_empty() => continue, + Ok(msg) => msg, + Err(inquire::InquireError::OperationInterrupted) => break, + Err(e) => return Err(e.into()), + }; + + let request = ChatRequest { + project_id: project_id.to_string(), + environment_id: environment_id.to_string(), + message, + thread_id: thread_id.clone(), + service_id: service_id.map(|s| s.to_string()), + }; + + println!(); + let mut spinner: Option = None; + + stream_chat(client, url, &request, |event| { + if let ChatEvent::Metadata { thread_id: ref tid, .. } = event { + thread_id = Some(tid.clone()); + } + if json { + handle_event_json(&event); + } else { + handle_event_human(event, &mut spinner); + } + }).await?; + + println!(); + } + + Ok(()) +} + +fn handle_event_human(event: ChatEvent, spinner: &mut Option) { + match event { + ChatEvent::Chunk { text } => { + if let Some(s) = spinner.take() { + s.finish_and_clear(); + } + print!("{}", text); + let _ = std::io::stdout().flush(); + } + ChatEvent::ToolCallReady { tool_name, .. } => { + *spinner = Some(create_spinner(format!("Running: {tool_name}"))); + } + ChatEvent::ToolExecutionComplete { is_error, .. } => { + if let Some(s) = spinner { + if is_error { + fail_spinner(s, "Tool failed".to_string()); + } else { + success_spinner(s, "Done".to_string()); + } + } + *spinner = None; + } + ChatEvent::Error { message } => { + eprintln!("{}: {}", "Error".red().bold(), message); + } + ChatEvent::WorkflowCompleted { .. } => { + println!(); + } + ChatEvent::Metadata { .. } => { + // Thread ID captured by caller; no output + } + } +} + +fn handle_event_json(event: &ChatEvent) { + if let Ok(json) = serde_json::to_string(event) { + println!("{json}"); + } +} diff --git a/src/controllers/chat.rs b/src/controllers/chat.rs new file mode 100644 index 000000000..d407c1edc --- /dev/null +++ b/src/controllers/chat.rs @@ -0,0 +1,180 @@ +use std::time::Duration; + +use anyhow::{Result, bail}; +use reqwest::{ + Client, + header::{HeaderMap, HeaderValue}, +}; +use serde::{Deserialize, Serialize}; + +use crate::{ + client::auth_failure_error, + commands::Environment, + config::Configs, + consts, + errors::RailwayError, +}; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatRequest { + pub project_id: String, + pub environment_id: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub thread_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub service_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ChatEvent { + Metadata { + #[serde(rename = "threadId")] + thread_id: String, + #[serde(rename = "streamId")] + stream_id: String, + }, + Chunk { + text: String, + }, + ToolCallReady { + #[serde(rename = "toolCallId")] + tool_call_id: String, + #[serde(rename = "toolName")] + tool_name: String, + args: serde_json::Value, + }, + ToolExecutionComplete { + #[serde(rename = "toolCallId")] + tool_call_id: String, + result: serde_json::Value, + #[serde(rename = "isError")] + is_error: bool, + }, + Error { + message: String, + }, + WorkflowCompleted { + #[serde(rename = "completedAt")] + completed_at: String, + }, +} + +pub fn get_chat_url(configs: &Configs) -> String { + format!("https://backboard.{}/api/v1/chat", configs.get_host()) +} + +pub fn build_chat_client(configs: &Configs) -> Result { + let mut headers = HeaderMap::new(); + if let Some(token) = &Configs::get_railway_token() { + headers.insert("project-access-token", HeaderValue::from_str(token)?); + } else if let Some(token) = configs.get_railway_auth_token() { + headers.insert( + "authorization", + HeaderValue::from_str(&format!("Bearer {token}"))?, + ); + } else { + return Err(RailwayError::Unauthorized); + } + headers.insert( + "x-source", + HeaderValue::from_static(consts::get_user_agent()), + ); + let client = Client::builder() + .danger_accept_invalid_certs(matches!(Configs::get_environment_id(), Environment::Dev)) + .user_agent(consts::get_user_agent()) + .default_headers(headers) + .connect_timeout(Duration::from_secs(30)) + // No overall timeout — SSE streams are long-lived + .build() + .unwrap(); + Ok(client) +} + +pub async fn stream_chat( + client: &Client, + url: &str, + request: &ChatRequest, + mut on_event: impl FnMut(ChatEvent), +) -> Result<()> { + let mut response = client + .post(url) + .header("Accept", "text/event-stream") + .header("Content-Type", "application/json") + .json(request) + .send() + .await?; + + let status = response.status(); + if !status.is_success() { + match status.as_u16() { + 401 | 403 => return Err(auth_failure_error().into()), + 429 => return Err(RailwayError::Ratelimited.into()), + _ => { + let body = response.text().await.unwrap_or_default(); + bail!("Chat request failed ({}): {}", status, body); + } + } + } + + let mut buffer = String::new(); + let mut current_event_type = String::new(); + let mut current_data = String::new(); + + while let Some(chunk) = response.chunk().await? { + buffer.push_str(&String::from_utf8_lossy(&chunk)); + + while let Some(line_end) = buffer.find('\n') { + let line = buffer[..line_end].trim_end_matches('\r').to_string(); + buffer = buffer[line_end + 1..].to_string(); + + if line.is_empty() { + // Empty line signals end of SSE event + if !current_data.is_empty() { + if let Some(event) = parse_sse_event(¤t_event_type, ¤t_data) { + on_event(event); + } + current_event_type.clear(); + current_data.clear(); + } + } else if let Some(value) = line.strip_prefix("event: ") { + current_event_type = value.to_string(); + } else if let Some(value) = line.strip_prefix("data: ") { + if !current_data.is_empty() { + current_data.push('\n'); + } + current_data.push_str(value); + } + // Ignore comments (lines starting with :) and unknown fields + } + } + + Ok(()) +} + +fn parse_sse_event(event_type: &str, data: &str) -> Option { + match event_type { + "metadata" => serde_json::from_str(data).ok().map(|v: serde_json::Value| { + ChatEvent::Metadata { + thread_id: v["threadId"].as_str().unwrap_or_default().to_string(), + stream_id: v["streamId"].as_str().unwrap_or_default().to_string(), + } + }), + "chunk" => serde_json::from_str(data).ok().map(|v: serde_json::Value| { + ChatEvent::Chunk { + text: v["text"].as_str().unwrap_or_default().to_string(), + } + }), + "tool_call_ready" => serde_json::from_str(data).ok(), + "tool_execution_complete" => serde_json::from_str(data).ok(), + "error" => serde_json::from_str(data).ok().map(|v: serde_json::Value| { + ChatEvent::Error { + message: v["message"].as_str().unwrap_or("Unknown error").to_string(), + } + }), + "workflow_completed" => serde_json::from_str(data).ok(), + _ => None, // Ignore unknown event types + } +} diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs index 0320dbe1a..9d939cf8c 100644 --- a/src/controllers/mod.rs +++ b/src/controllers/mod.rs @@ -1,3 +1,4 @@ +pub mod chat; pub mod config; pub mod database; pub mod deployment; diff --git a/src/macros.rs b/src/macros.rs index 064743534..ed90fdbf3 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -41,6 +41,15 @@ macro_rules! commands { cmd = cmd.subcommand(sub); } )* + cmd = cmd.arg( + clap::Arg::new("prompt") + .short('p') + .long("prompt") + .help("Send a natural language prompt to Railway AI") + .value_name("MESSAGE") + .num_args(0..=1) + .default_missing_value("") + ); cmd } diff --git a/src/main.rs b/src/main.rs index 2076e8bf1..72555ba6e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -306,10 +306,17 @@ async fn main() -> Result<()> { "check_updates", ]; - let needs_refresh = cli - .subcommand_name() - .map(|cmd| !NO_AUTH_COMMANDS.contains(&cmd)) - .unwrap_or(false); + // Check for -p / --prompt flag before subcommand dispatch + let is_prompt = cli.contains_id("prompt") + && cli.value_source("prompt") == Some(clap::parser::ValueSource::CommandLine); + + let needs_refresh = if is_prompt { + true + } else { + cli.subcommand_name() + .map(|cmd| !NO_AUTH_COMMANDS.contains(&cmd)) + .unwrap_or(false) + }; if needs_refresh { if let Ok(mut configs) = Configs::new() { @@ -319,7 +326,18 @@ async fn main() -> Result<()> { } } - let exec_result = exec_cli(cli).await; + let exec_result = if is_prompt { + let message = cli.get_one::("prompt").cloned().filter(|s| !s.is_empty()); + commands::prompt::command(commands::prompt::Args { + message, + json: false, + thread_id: None, + service: None, + environment: None, + }).await + } else { + exec_cli(cli).await + }; // Send telemetry for silent auto-update apply (after auth is available). if let Some(ref version) = auto_applied_version { From 7ec553f804d50cb6021b8f4300ae7a3a0ff686d7 Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Sun, 12 Apr 2026 11:34:57 -0700 Subject: [PATCH 2/8] feat: improve prompt UI and add --json output - Add "Thinking..." spinner on initial request - Add "Railway AI:" header before response text - Dim tool call spinners and suppress them in non-TTY mode - Use get_or_prompt_service() for interactive service selection - Add --json root flag that outputs accumulated response as a single JSON object with threadId, response text, and toolCalls - Add telemetry tracking for the -p flag path - Improve non-interactive error message with flag hint Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/prompt.rs | 219 +++++++++++++++++++++++++++++++---------- src/macros.rs | 6 ++ src/main.rs | 24 ++++- 3 files changed, 196 insertions(+), 53 deletions(-) diff --git a/src/commands/prompt.rs b/src/commands/prompt.rs index 518d36b65..895b4482c 100644 --- a/src/commands/prompt.rs +++ b/src/commands/prompt.rs @@ -1,11 +1,14 @@ use std::io::Write; -use anyhow::Context; +use is_terminal::IsTerminal; +use serde::Serialize; + use crate::{ controllers::{ chat::{ChatEvent, ChatRequest, build_chat_client, get_chat_url, stream_chat}, environment::get_matched_environment, project::get_project, + service::get_or_prompt_service, }, interact_or, util::progress::{create_spinner, fail_spinner, success_spinner}, @@ -21,52 +24,76 @@ pub struct Args { pub environment: Option, } +#[derive(Default, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonResponse { + #[serde(skip_serializing_if = "Option::is_none")] + thread_id: Option, + response: String, + tool_calls: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonToolCall { + tool_name: String, + args: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + is_error: bool, +} + pub async fn command(args: Args) -> Result<()> { let configs = Configs::new()?; let linked_project = configs.get_linked_project().await?; + let project_id = linked_project.project.clone(); let client = GQLClient::new_authorized(&configs)?; - let project = get_project(&client, &configs, linked_project.project.clone()).await?; + let project = get_project(&client, &configs, project_id.clone()).await?; let environment_id = match args.environment.clone() { Some(env) => get_matched_environment(&project, env)?.id, None => linked_project.environment_id()?.to_string(), }; - let service_id = match args.service { - Some(ref service_arg) => { - let svc = project - .services - .edges - .iter() - .find(|s| s.node.name == *service_arg || s.node.id == *service_arg) - .with_context(|| format!("Service '{service_arg}' not found"))?; - Some(svc.node.id.clone()) - } - None => linked_project.service.clone(), - }; + let service_id = get_or_prompt_service( + Some(linked_project), + project, + args.service, + ) + .await?; let chat_client = build_chat_client(&configs)?; let url = get_chat_url(&configs); + let is_tty = std::io::stdout().is_terminal(); if let Some(message) = args.message { - run_single_shot(&chat_client, &url, &ChatRequest { - project_id: linked_project.project.clone(), - environment_id, - message, - thread_id: args.thread_id, - service_id, - }, args.json).await + run_single_shot( + &chat_client, + &url, + &ChatRequest { + project_id, + environment_id, + message, + thread_id: args.thread_id, + service_id, + }, + args.json, + is_tty, + ) + .await } else { run_repl( &chat_client, &url, - &linked_project.project, + &project_id, &environment_id, service_id.as_deref(), args.thread_id, args.json, - ).await + is_tty, + ) + .await } } @@ -75,16 +102,32 @@ async fn run_single_shot( url: &str, request: &ChatRequest, json: bool, + is_tty: bool, ) -> Result<()> { - let mut spinner: Option = None; + if json { + let mut response = JsonResponse::default(); - stream_chat(client, url, request, |event| { - if json { - handle_event_json(&event); - } else { - handle_event_human(event, &mut spinner); + stream_chat(client, url, request, |event| { + accumulate_json_event(event, &mut response); + }) + .await?; + + println!("{}", serde_json::to_string_pretty(&response).unwrap()); + Ok(()) + } else { + let mut spinner: Option = None; + let mut has_printed_text = false; + + // Show a thinking spinner while waiting for the first event + if is_tty { + spinner = Some(create_spinner("Thinking...".dimmed().to_string())); } - }).await + + stream_chat(client, url, request, |event| { + handle_event_human(event, &mut spinner, &mut has_printed_text, is_tty); + }) + .await + } } async fn run_repl( @@ -95,13 +138,13 @@ async fn run_repl( service_id: Option<&str>, initial_thread_id: Option, json: bool, + is_tty: bool, ) -> Result<()> { - interact_or!("Interactive chat requires a terminal. Pass a message as an argument for non-interactive use."); + interact_or!("Interactive mode requires a terminal. Use `railway -p \"your message\"` for non-interactive use."); println!( "{}", - "Railway Chat (type 'exit' or Ctrl+C to quit)" - .dimmed() + "Railway AI (type 'exit' or Ctrl+C to quit)".dimmed() ); println!(); @@ -113,7 +156,12 @@ async fn run_repl( .prompt(); let message = match input { - Ok(msg) if msg.trim().eq_ignore_ascii_case("exit") || msg.trim().eq_ignore_ascii_case("quit") => break, + Ok(msg) + if msg.trim().eq_ignore_ascii_case("exit") + || msg.trim().eq_ignore_ascii_case("quit") => + { + break + } Ok(msg) if msg.trim().is_empty() => continue, Ok(msg) => msg, Err(inquire::InquireError::OperationInterrupted) => break, @@ -129,18 +177,41 @@ async fn run_repl( }; println!(); - let mut spinner: Option = None; - stream_chat(client, url, &request, |event| { - if let ChatEvent::Metadata { thread_id: ref tid, .. } = event { - thread_id = Some(tid.clone()); - } - if json { - handle_event_json(&event); - } else { - handle_event_human(event, &mut spinner); + if json { + let mut response = JsonResponse::default(); + + stream_chat(client, url, &request, |event| { + if let ChatEvent::Metadata { + thread_id: ref tid, .. + } = event + { + thread_id = Some(tid.clone()); + } + accumulate_json_event(event, &mut response); + }) + .await?; + + println!("{}", serde_json::to_string_pretty(&response).unwrap()); + } else { + let mut spinner: Option = None; + let mut has_printed_text = false; + + if is_tty { + spinner = Some(create_spinner("Thinking...".dimmed().to_string())); } - }).await?; + + stream_chat(client, url, &request, |event| { + if let ChatEvent::Metadata { + thread_id: ref tid, .. + } = event + { + thread_id = Some(tid.clone()); + } + handle_event_human(event, &mut spinner, &mut has_printed_text, is_tty); + }) + .await?; + } println!(); } @@ -148,29 +219,50 @@ async fn run_repl( Ok(()) } -fn handle_event_human(event: ChatEvent, spinner: &mut Option) { +fn handle_event_human( + event: ChatEvent, + spinner: &mut Option, + has_printed_text: &mut bool, + is_tty: bool, +) { match event { ChatEvent::Chunk { text } => { if let Some(s) = spinner.take() { s.finish_and_clear(); } + if !*has_printed_text { + println!(); + print!("{} ", "Railway AI:".purple().bold()); + *has_printed_text = true; + } print!("{}", text); let _ = std::io::stdout().flush(); } ChatEvent::ToolCallReady { tool_name, .. } => { - *spinner = Some(create_spinner(format!("Running: {tool_name}"))); + if is_tty { + // Clear any existing spinner before starting a new one + if let Some(s) = spinner.take() { + s.finish_and_clear(); + } + *spinner = Some(create_spinner( + format!("Running: {tool_name}").dimmed().to_string(), + )); + } } ChatEvent::ToolExecutionComplete { is_error, .. } => { if let Some(s) = spinner { if is_error { fail_spinner(s, "Tool failed".to_string()); } else { - success_spinner(s, "Done".to_string()); + success_spinner(s, "Done".dimmed().to_string()); } } *spinner = None; } ChatEvent::Error { message } => { + if let Some(s) = spinner.take() { + s.finish_and_clear(); + } eprintln!("{}: {}", "Error".red().bold(), message); } ChatEvent::WorkflowCompleted { .. } => { @@ -182,8 +274,35 @@ fn handle_event_human(event: ChatEvent, spinner: &mut Option { + response.thread_id = Some(thread_id); + } + ChatEvent::Chunk { text } => { + response.response.push_str(&text); + } + ChatEvent::ToolCallReady { + tool_name, args, .. + } => { + response.tool_calls.push(JsonToolCall { + tool_name, + args, + result: None, + is_error: false, + }); + } + ChatEvent::ToolExecutionComplete { + result, is_error, .. + } => { + if let Some(last) = response.tool_calls.last_mut() { + last.result = Some(result); + last.is_error = is_error; + } + } + ChatEvent::Error { message } => { + response.response.push_str(&format!("\nError: {message}")); + } + ChatEvent::WorkflowCompleted { .. } => {} } } diff --git a/src/macros.rs b/src/macros.rs index ed90fdbf3..e9cc38661 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -49,6 +49,12 @@ macro_rules! commands { .value_name("MESSAGE") .num_args(0..=1) .default_missing_value("") + ).arg( + clap::Arg::new("json") + .long("json") + .help("Output prompt response as JSON (requires -p)") + .requires("prompt") + .action(clap::ArgAction::SetTrue) ); cmd } diff --git a/src/main.rs b/src/main.rs index 72555ba6e..2e0b5e07f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -328,13 +328,31 @@ async fn main() -> Result<()> { let exec_result = if is_prompt { let message = cli.get_one::("prompt").cloned().filter(|s| !s.is_empty()); - commands::prompt::command(commands::prompt::Args { + let json = cli.get_flag("json"); + let start = std::time::Instant::now(); + let result = commands::prompt::command(commands::prompt::Args { message, - json: false, + json, thread_id: None, service: None, environment: None, - }).await + }).await; + let duration = start.elapsed(); + telemetry::send(telemetry::CliTrackEvent { + command: "prompt".to_string(), + sub_command: None, + success: result.is_ok(), + error_message: result.as_ref().err().map(|e| { + let msg = format!("{e}"); + if msg.len() > 256 { msg[..256].to_string() } else { msg } + }), + duration_ms: duration.as_millis() as u64, + cli_version: env!("CARGO_PKG_VERSION"), + os: std::env::consts::OS, + arch: std::env::consts::ARCH, + is_ci: Configs::env_is_ci(), + }).await; + result } else { exec_cli(cli).await }; From deab38ac435e95e825117f7a1686575394b5c0f0 Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Mon, 13 Apr 2026 21:54:56 -0700 Subject: [PATCH 3/8] style: fix rustfmt formatting in telemetry block Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 2e0b5e07f..a6d567901 100644 --- a/src/main.rs +++ b/src/main.rs @@ -344,14 +344,19 @@ async fn main() -> Result<()> { success: result.is_ok(), error_message: result.as_ref().err().map(|e| { let msg = format!("{e}"); - if msg.len() > 256 { msg[..256].to_string() } else { msg } + if msg.len() > 256 { + msg[..256].to_string() + } else { + msg + } }), duration_ms: duration.as_millis() as u64, cli_version: env!("CARGO_PKG_VERSION"), os: std::env::consts::OS, arch: std::env::consts::ARCH, is_ci: Configs::env_is_ci(), - }).await; + }) + .await; result } else { exec_cli(cli).await From ef34d2b7cedb28ee83556a87c1ce27ab14e7d525 Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Mon, 13 Apr 2026 21:58:46 -0700 Subject: [PATCH 4/8] fix: align chat client with current API contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Skip project access tokens in build_chat_client() — the chat API requires user OAuth tokens and rejects RAILWAY_TOKEN - Fix error event parsing: API sends `error` field, not `message` - Handle `aborted` events from the agent stream - Add fallback for both `error` and `message` field names for forward compatibility Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/prompt.rs | 11 +++++++++ src/controllers/chat.rs | 53 ++++++++++++++++++++++++++++------------- 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/src/commands/prompt.rs b/src/commands/prompt.rs index 895b4482c..7c4a7e945 100644 --- a/src/commands/prompt.rs +++ b/src/commands/prompt.rs @@ -265,6 +265,13 @@ fn handle_event_human( } eprintln!("{}: {}", "Error".red().bold(), message); } + ChatEvent::Aborted { reason } => { + if let Some(s) = spinner.take() { + s.finish_and_clear(); + } + let msg = reason.unwrap_or_else(|| "Request was aborted".to_string()); + eprintln!("{}: {}", "Aborted".yellow().bold(), msg); + } ChatEvent::WorkflowCompleted { .. } => { println!(); } @@ -303,6 +310,10 @@ fn accumulate_json_event(event: ChatEvent, response: &mut JsonResponse) { ChatEvent::Error { message } => { response.response.push_str(&format!("\nError: {message}")); } + ChatEvent::Aborted { reason } => { + let msg = reason.unwrap_or_else(|| "Request was aborted".to_string()); + response.response.push_str(&format!("\nAborted: {msg}")); + } ChatEvent::WorkflowCompleted { .. } => {} } } diff --git a/src/controllers/chat.rs b/src/controllers/chat.rs index d407c1edc..bc8e48891 100644 --- a/src/controllers/chat.rs +++ b/src/controllers/chat.rs @@ -56,6 +56,10 @@ pub enum ChatEvent { Error { message: String, }, + Aborted { + #[serde(default)] + reason: Option, + }, WorkflowCompleted { #[serde(rename = "completedAt")] completed_at: String, @@ -66,11 +70,14 @@ pub fn get_chat_url(configs: &Configs) -> String { format!("https://backboard.{}/api/v1/chat", configs.get_host()) } +/// Build an HTTP client for the chat API. +/// +/// The chat endpoint requires user OAuth tokens — project access tokens +/// (`RAILWAY_TOKEN`) are not supported. We skip project tokens and only +/// use the user's OAuth bearer token. pub fn build_chat_client(configs: &Configs) -> Result { let mut headers = HeaderMap::new(); - if let Some(token) = &Configs::get_railway_token() { - headers.insert("project-access-token", HeaderValue::from_str(token)?); - } else if let Some(token) = configs.get_railway_auth_token() { + if let Some(token) = configs.get_railway_auth_token() { headers.insert( "authorization", HeaderValue::from_str(&format!("Bearer {token}"))?, @@ -156,25 +163,37 @@ pub async fn stream_chat( fn parse_sse_event(event_type: &str, data: &str) -> Option { match event_type { - "metadata" => serde_json::from_str(data).ok().map(|v: serde_json::Value| { - ChatEvent::Metadata { + "metadata" => serde_json::from_str(data) + .ok() + .map(|v: serde_json::Value| ChatEvent::Metadata { thread_id: v["threadId"].as_str().unwrap_or_default().to_string(), stream_id: v["streamId"].as_str().unwrap_or_default().to_string(), - } - }), - "chunk" => serde_json::from_str(data).ok().map(|v: serde_json::Value| { - ChatEvent::Chunk { + }), + "chunk" => serde_json::from_str(data) + .ok() + .map(|v: serde_json::Value| ChatEvent::Chunk { text: v["text"].as_str().unwrap_or_default().to_string(), - } - }), + }), "tool_call_ready" => serde_json::from_str(data).ok(), "tool_execution_complete" => serde_json::from_str(data).ok(), - "error" => serde_json::from_str(data).ok().map(|v: serde_json::Value| { - ChatEvent::Error { - message: v["message"].as_str().unwrap_or("Unknown error").to_string(), - } - }), + "error" => serde_json::from_str(data) + .ok() + .map(|v: serde_json::Value| ChatEvent::Error { + message: v["error"] + .as_str() + .or_else(|| v["message"].as_str()) + .unwrap_or("Unknown error") + .to_string(), + }), + "aborted" => serde_json::from_str(data) + .ok() + .map(|v: serde_json::Value| ChatEvent::Aborted { + reason: v["reason"].as_str().map(|s| s.to_string()), + }), "workflow_completed" => serde_json::from_str(data).ok(), - _ => None, // Ignore unknown event types + // Ignore events we don't need to surface: started, tool_call_streaming_start, + // tool_call_delta, tool_execution_start, tool_output_delta, step_finish, + // completed, subagent_start, subagent_complete + _ => None, } } From 8a6fd56cfaf7d6e2b83728dbc8555a062df4a0a7 Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Mon, 13 Apr 2026 22:09:11 -0700 Subject: [PATCH 5/8] style: fix rustfmt chain and await formatting Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index a6d567901..94dddcaca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -327,7 +327,10 @@ async fn main() -> Result<()> { } let exec_result = if is_prompt { - let message = cli.get_one::("prompt").cloned().filter(|s| !s.is_empty()); + let message = cli + .get_one::("prompt") + .cloned() + .filter(|s| !s.is_empty()); let json = cli.get_flag("json"); let start = std::time::Instant::now(); let result = commands::prompt::command(commands::prompt::Args { @@ -336,7 +339,8 @@ async fn main() -> Result<()> { thread_id: None, service: None, environment: None, - }).await; + }) + .await; let duration = start.elapsed(); telemetry::send(telemetry::CliTrackEvent { command: "prompt".to_string(), From eb3f79d6f0bd6986b2bac1237e33a00e50542ce5 Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Tue, 14 Apr 2026 14:54:51 -0700 Subject: [PATCH 6/8] style: fix rustfmt formatting across prompt and chat modules Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/prompt.rs | 18 ++++++------------ src/controllers/chat.rs | 31 ++++++++++++++++--------------- 2 files changed, 22 insertions(+), 27 deletions(-) diff --git a/src/commands/prompt.rs b/src/commands/prompt.rs index 7c4a7e945..d8f448fff 100644 --- a/src/commands/prompt.rs +++ b/src/commands/prompt.rs @@ -56,12 +56,7 @@ pub async fn command(args: Args) -> Result<()> { None => linked_project.environment_id()?.to_string(), }; - let service_id = get_or_prompt_service( - Some(linked_project), - project, - args.service, - ) - .await?; + let service_id = get_or_prompt_service(Some(linked_project), project, args.service).await?; let chat_client = build_chat_client(&configs)?; let url = get_chat_url(&configs); @@ -140,12 +135,11 @@ async fn run_repl( json: bool, is_tty: bool, ) -> Result<()> { - interact_or!("Interactive mode requires a terminal. Use `railway -p \"your message\"` for non-interactive use."); - - println!( - "{}", - "Railway AI (type 'exit' or Ctrl+C to quit)".dimmed() + interact_or!( + "Interactive mode requires a terminal. Use `railway -p \"your message\"` for non-interactive use." ); + + println!("{}", "Railway AI (type 'exit' or Ctrl+C to quit)".dimmed()); println!(); let mut thread_id = initial_thread_id; @@ -160,7 +154,7 @@ async fn run_repl( if msg.trim().eq_ignore_ascii_case("exit") || msg.trim().eq_ignore_ascii_case("quit") => { - break + break; } Ok(msg) if msg.trim().is_empty() => continue, Ok(msg) => msg, diff --git a/src/controllers/chat.rs b/src/controllers/chat.rs index bc8e48891..6c221a918 100644 --- a/src/controllers/chat.rs +++ b/src/controllers/chat.rs @@ -8,10 +8,7 @@ use reqwest::{ use serde::{Deserialize, Serialize}; use crate::{ - client::auth_failure_error, - commands::Environment, - config::Configs, - consts, + client::auth_failure_error, commands::Environment, config::Configs, consts, errors::RailwayError, }; @@ -163,12 +160,14 @@ pub async fn stream_chat( fn parse_sse_event(event_type: &str, data: &str) -> Option { match event_type { - "metadata" => serde_json::from_str(data) - .ok() - .map(|v: serde_json::Value| ChatEvent::Metadata { - thread_id: v["threadId"].as_str().unwrap_or_default().to_string(), - stream_id: v["streamId"].as_str().unwrap_or_default().to_string(), - }), + "metadata" => { + serde_json::from_str(data) + .ok() + .map(|v: serde_json::Value| ChatEvent::Metadata { + thread_id: v["threadId"].as_str().unwrap_or_default().to_string(), + stream_id: v["streamId"].as_str().unwrap_or_default().to_string(), + }) + } "chunk" => serde_json::from_str(data) .ok() .map(|v: serde_json::Value| ChatEvent::Chunk { @@ -185,11 +184,13 @@ fn parse_sse_event(event_type: &str, data: &str) -> Option { .unwrap_or("Unknown error") .to_string(), }), - "aborted" => serde_json::from_str(data) - .ok() - .map(|v: serde_json::Value| ChatEvent::Aborted { - reason: v["reason"].as_str().map(|s| s.to_string()), - }), + "aborted" => { + serde_json::from_str(data) + .ok() + .map(|v: serde_json::Value| ChatEvent::Aborted { + reason: v["reason"].as_str().map(|s| s.to_string()), + }) + } "workflow_completed" => serde_json::from_str(data).ok(), // Ignore events we don't need to surface: started, tool_call_streaming_start, // tool_call_delta, tool_execution_start, tool_output_delta, step_finish, From 995df6c1324e39c382d40ec8e02402f64f84dd9c Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Wed, 15 Apr 2026 17:36:26 -0700 Subject: [PATCH 7/8] feat: rename prompt to agent subcommand with UI polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move from root-level `-p` flag to `railway agent` subcommand - `railway agent` enters interactive REPL - `railway agent -p "message"` sends a single prompt - Rename "Railway AI" to "Railway Agent" throughout - Add train-themed randomized thinking messages - Add git-tree style connector (╰─) linking thinking to tool calls - Tool calls render in grey box with white text - Reset "Railway Agent:" prefix for each new response block - Telemetry now handled automatically via commands! macro Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/{prompt.rs => agent.rs} | 94 ++++++++++++++++++++++------ src/commands/mod.rs | 2 +- src/macros.rs | 15 ----- src/main.rs | 56 ++--------------- 4 files changed, 83 insertions(+), 84 deletions(-) rename src/commands/{prompt.rs => agent.rs} (74%) diff --git a/src/commands/prompt.rs b/src/commands/agent.rs similarity index 74% rename from src/commands/prompt.rs rename to src/commands/agent.rs index d8f448fff..aafd1fbc7 100644 --- a/src/commands/prompt.rs +++ b/src/commands/agent.rs @@ -1,8 +1,22 @@ use std::io::Write; use is_terminal::IsTerminal; +use rand::Rng; use serde::Serialize; +const THINKING_MESSAGES: &[&str] = &[ + "Chugging along...", + "Full steam ahead...", + "Leaving the station...", + "Building up steam...", + "Coupling the cars...", + "Switching tracks...", + "Rolling down the line...", + "Stoking the engine...", + "Pulling into the yard...", + "All aboard...", +]; + use crate::{ controllers::{ chat::{ChatEvent, ChatRequest, build_chat_client, get_chat_url, stream_chat}, @@ -16,12 +30,35 @@ use crate::{ use super::*; +/// Interact with the Railway Agent +#[derive(Parser)] +#[clap( + about = "Interact with the Railway Agent", + after_help = "Examples:\n\n\ + railway agent # Interactive mode\n\ + railway agent -p \"what's the status of my deployment?\" # Single prompt\n\ + railway agent -p \"why is my service crashing?\" --json # JSON output" +)] pub struct Args { - pub message: Option, - pub json: bool, - pub thread_id: Option, - pub service: Option, - pub environment: Option, + /// Send a single prompt (omit for interactive mode) + #[clap(short, long, value_name = "MESSAGE")] + prompt: Option, + + /// Output in JSON format + #[clap(long)] + json: bool, + + /// Continue an existing chat thread + #[clap(long, value_name = "ID")] + thread_id: Option, + + /// Service to scope the chat to (name or ID) + #[clap(short, long)] + service: Option, + + /// Environment to use (defaults to linked environment) + #[clap(short, long)] + environment: Option, } #[derive(Default, Serialize)] @@ -62,7 +99,7 @@ pub async fn command(args: Args) -> Result<()> { let url = get_chat_url(&configs); let is_tty = std::io::stdout().is_terminal(); - if let Some(message) = args.message { + if let Some(message) = args.prompt { run_single_shot( &chat_client, &url, @@ -115,7 +152,9 @@ async fn run_single_shot( // Show a thinking spinner while waiting for the first event if is_tty { - spinner = Some(create_spinner("Thinking...".dimmed().to_string())); + let msg = THINKING_MESSAGES[rand::thread_rng().gen_range(0..THINKING_MESSAGES.len())]; + println!(); + spinner = Some(create_spinner(msg.dimmed().to_string())); } stream_chat(client, url, request, |event| { @@ -139,7 +178,7 @@ async fn run_repl( "Interactive mode requires a terminal. Use `railway -p \"your message\"` for non-interactive use." ); - println!("{}", "Railway AI (type 'exit' or Ctrl+C to quit)".dimmed()); + println!("{}", "Railway Agent (type 'exit' or Ctrl+C to quit)".dimmed()); println!(); let mut thread_id = initial_thread_id; @@ -170,8 +209,6 @@ async fn run_repl( service_id: service_id.map(|s| s.to_string()), }; - println!(); - if json { let mut response = JsonResponse::default(); @@ -192,7 +229,9 @@ async fn run_repl( let mut has_printed_text = false; if is_tty { - spinner = Some(create_spinner("Thinking...".dimmed().to_string())); + let msg = THINKING_MESSAGES[rand::thread_rng().gen_range(0..THINKING_MESSAGES.len())]; + println!(); + spinner = Some(create_spinner(msg.dimmed().to_string())); } stream_chat(client, url, &request, |event| { @@ -226,7 +265,7 @@ fn handle_event_human( } if !*has_printed_text { println!(); - print!("{} ", "Railway AI:".purple().bold()); + print!("{} ", "Railway Agent:".purple().bold()); *has_printed_text = true; } print!("{}", text); @@ -234,21 +273,40 @@ fn handle_event_human( } ChatEvent::ToolCallReady { tool_name, .. } => { if is_tty { - // Clear any existing spinner before starting a new one if let Some(s) = spinner.take() { s.finish_and_clear(); } - *spinner = Some(create_spinner( - format!("Running: {tool_name}").dimmed().to_string(), - )); + *has_printed_text = false; + println!(); + *spinner = Some(create_spinner(format!( + "{} {}", + "╰─".dimmed(), + format!(" Agent Tool: {tool_name} ") + .truecolor(255, 255, 255) + .on_truecolor(68, 68, 68) + ))); } } ChatEvent::ToolExecutionComplete { is_error, .. } => { if let Some(s) = spinner { if is_error { - fail_spinner(s, "Tool failed".to_string()); + fail_spinner( + s, + format!( + "{}", + " Tool failed " + .truecolor(255, 255, 255) + .on_truecolor(68, 68, 68) + ), + ); } else { - success_spinner(s, "Done".dimmed().to_string()); + success_spinner( + s, + format!( + "{}", + " Done ".truecolor(255, 255, 255).on_truecolor(68, 68, 68) + ), + ); } } *spinner = None; diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 38cd77e5f..af21b6db6 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -48,5 +48,5 @@ pub mod variable; pub mod volume; pub mod whoami; +pub mod agent; pub mod check_updates; -pub mod prompt; diff --git a/src/macros.rs b/src/macros.rs index e9cc38661..064743534 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -41,21 +41,6 @@ macro_rules! commands { cmd = cmd.subcommand(sub); } )* - cmd = cmd.arg( - clap::Arg::new("prompt") - .short('p') - .long("prompt") - .help("Send a natural language prompt to Railway AI") - .value_name("MESSAGE") - .num_args(0..=1) - .default_missing_value("") - ).arg( - clap::Arg::new("json") - .long("json") - .help("Output prompt response as JSON (requires -p)") - .requires("prompt") - .action(clap::ArgAction::SetTrue) - ); cmd } diff --git a/src/main.rs b/src/main.rs index 94dddcaca..62e769f04 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,6 +29,7 @@ mod telemetry; // Specify the modules you want to include in the commands_enum! macro commands!( add, + agent, autoupdate, bucket, completion, @@ -306,17 +307,10 @@ async fn main() -> Result<()> { "check_updates", ]; - // Check for -p / --prompt flag before subcommand dispatch - let is_prompt = cli.contains_id("prompt") - && cli.value_source("prompt") == Some(clap::parser::ValueSource::CommandLine); - - let needs_refresh = if is_prompt { - true - } else { - cli.subcommand_name() - .map(|cmd| !NO_AUTH_COMMANDS.contains(&cmd)) - .unwrap_or(false) - }; + let needs_refresh = cli + .subcommand_name() + .map(|cmd| !NO_AUTH_COMMANDS.contains(&cmd)) + .unwrap_or(false); if needs_refresh { if let Ok(mut configs) = Configs::new() { @@ -326,45 +320,7 @@ async fn main() -> Result<()> { } } - let exec_result = if is_prompt { - let message = cli - .get_one::("prompt") - .cloned() - .filter(|s| !s.is_empty()); - let json = cli.get_flag("json"); - let start = std::time::Instant::now(); - let result = commands::prompt::command(commands::prompt::Args { - message, - json, - thread_id: None, - service: None, - environment: None, - }) - .await; - let duration = start.elapsed(); - telemetry::send(telemetry::CliTrackEvent { - command: "prompt".to_string(), - sub_command: None, - success: result.is_ok(), - error_message: result.as_ref().err().map(|e| { - let msg = format!("{e}"); - if msg.len() > 256 { - msg[..256].to_string() - } else { - msg - } - }), - duration_ms: duration.as_millis() as u64, - cli_version: env!("CARGO_PKG_VERSION"), - os: std::env::consts::OS, - arch: std::env::consts::ARCH, - is_ci: Configs::env_is_ci(), - }) - .await; - result - } else { - exec_cli(cli).await - }; + let exec_result = exec_cli(cli).await; // Send telemetry for silent auto-update apply (after auth is available). if let Some(ref version) = auto_applied_version { From 54053c74863a435e82ba43a7a41946b38359b74d Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Thu, 16 Apr 2026 10:17:17 -0700 Subject: [PATCH 8/8] style: fix rustfmt formatting in agent REPL Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/agent.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/commands/agent.rs b/src/commands/agent.rs index aafd1fbc7..367b26717 100644 --- a/src/commands/agent.rs +++ b/src/commands/agent.rs @@ -178,7 +178,10 @@ async fn run_repl( "Interactive mode requires a terminal. Use `railway -p \"your message\"` for non-interactive use." ); - println!("{}", "Railway Agent (type 'exit' or Ctrl+C to quit)".dimmed()); + println!( + "{}", + "Railway Agent (type 'exit' or Ctrl+C to quit)".dimmed() + ); println!(); let mut thread_id = initial_thread_id; @@ -229,9 +232,10 @@ async fn run_repl( let mut has_printed_text = false; if is_tty { - let msg = THINKING_MESSAGES[rand::thread_rng().gen_range(0..THINKING_MESSAGES.len())]; - println!(); - spinner = Some(create_spinner(msg.dimmed().to_string())); + let msg = + THINKING_MESSAGES[rand::thread_rng().gen_range(0..THINKING_MESSAGES.len())]; + println!(); + spinner = Some(create_spinner(msg.dimmed().to_string())); } stream_chat(client, url, &request, |event| {