//! Interactive CLI for Hero Supervisor OpenRPC Client //! //! This CLI provides an interactive interface to explore and test OpenRPC methods //! with arrow key navigation, parameter input, and response display. use clap::Parser; use crossterm::{ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::{ backend::CrosstermBackend, layout::{Alignment, Constraint, Direction, Layout, Margin, Rect}, style::{Color, Modifier, Style}, text::{Line, Span, Text}, widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, Frame, Terminal, }; use serde_json::json; use std::io; use chrono; use hero_supervisor_openrpc_client::{SupervisorClient, RunnerConfig, RunnerType, ProcessManagerType}; use std::path::PathBuf; #[derive(Parser)] #[command(name = "openrpc-cli")] #[command(about = "Interactive CLI for Hero Supervisor OpenRPC")] struct Cli { /// OpenRPC server URL #[arg(short, long, default_value = "http://127.0.0.1:3030")] url: String, } #[derive(Debug, Clone)] struct RpcMethod { name: String, description: String, params: Vec, } #[derive(Debug, Clone)] struct RpcParam { name: String, param_type: String, required: bool, description: String, } struct App { client: SupervisorClient, methods: Vec, list_state: ListState, current_screen: Screen, selected_method: Option, param_inputs: Vec, current_param_index: usize, response: Option, error_message: Option, } #[derive(Debug, PartialEq)] enum Screen { MethodList, ParamInput, Response, } impl App { async fn new(url: String) -> Result> { let client = SupervisorClient::new(&url)?; // Test connection to OpenRPC server using the standard rpc.discover method // This is the proper OpenRPC way to test server connectivity and discover available methods let discovery_result = client.discover().await; match discovery_result { Ok(discovery_info) => { println!("✓ Connected to OpenRPC server at {}", url); if let Some(info) = discovery_info.get("info") { if let Some(title) = info.get("title").and_then(|t| t.as_str()) { println!(" Server: {}", title); } if let Some(version) = info.get("version").and_then(|v| v.as_str()) { println!(" Version: {}", version); } } } Err(e) => { return Err(format!("Failed to connect to OpenRPC server at {}: {}\nMake sure the supervisor is running with OpenRPC enabled.", url, e).into()); } } let methods = vec![ RpcMethod { name: "list_runners".to_string(), description: "List all registered runners".to_string(), params: vec![], }, RpcMethod { name: "register_runner".to_string(), description: "Register a new runner to the supervisor with secret authentication".to_string(), params: vec![ RpcParam { name: "secret".to_string(), param_type: "String".to_string(), required: true, description: "Secret required for runner registration".to_string(), }, RpcParam { name: "name".to_string(), param_type: "String".to_string(), required: true, description: "Name of the runner".to_string(), }, RpcParam { name: "queue".to_string(), param_type: "String".to_string(), required: true, description: "Queue name for the runner to listen to".to_string(), }, ], }, RpcMethod { name: "run_job".to_string(), description: "Run a job on the appropriate runner".to_string(), params: vec![ RpcParam { name: "secret".to_string(), param_type: "String".to_string(), required: true, description: "Secret required for job execution".to_string(), }, RpcParam { name: "job_id".to_string(), param_type: "String".to_string(), required: true, description: "Job ID".to_string(), }, RpcParam { name: "runner_name".to_string(), param_type: "String".to_string(), required: true, description: "Name of the runner to execute the job".to_string(), }, RpcParam { name: "payload".to_string(), param_type: "String".to_string(), required: true, description: "Job payload/script content".to_string(), }, ], }, RpcMethod { name: "remove_runner".to_string(), description: "Remove a runner from the supervisor".to_string(), params: vec![ RpcParam { name: "actor_id".to_string(), param_type: "String".to_string(), required: true, description: "ID of the runner to remove".to_string(), }, ], }, RpcMethod { name: "start_runner".to_string(), description: "Start a specific runner".to_string(), params: vec![ RpcParam { name: "actor_id".to_string(), param_type: "String".to_string(), required: true, description: "ID of the runner to start".to_string(), }, ], }, RpcMethod { name: "stop_runner".to_string(), description: "Stop a specific runner".to_string(), params: vec![ RpcParam { name: "actor_id".to_string(), param_type: "String".to_string(), required: true, description: "ID of the runner to stop".to_string(), }, RpcParam { name: "force".to_string(), param_type: "bool".to_string(), required: true, description: "Whether to force stop the runner".to_string(), }, ], }, RpcMethod { name: "get_runner_status".to_string(), description: "Get the status of a specific runner".to_string(), params: vec![ RpcParam { name: "actor_id".to_string(), param_type: "String".to_string(), required: true, description: "ID of the runner".to_string(), }, ], }, RpcMethod { name: "get_all_runner_status".to_string(), description: "Get status of all runners".to_string(), params: vec![], }, RpcMethod { name: "start_all".to_string(), description: "Start all runners".to_string(), params: vec![], }, RpcMethod { name: "stop_all".to_string(), description: "Stop all runners".to_string(), params: vec![ RpcParam { name: "force".to_string(), param_type: "bool".to_string(), required: true, description: "Whether to force stop all runners".to_string(), }, ], }, RpcMethod { name: "get_all_status".to_string(), description: "Get status of all components".to_string(), params: vec![], }, ]; let mut list_state = ListState::default(); list_state.select(Some(0)); Ok(App { client, methods, list_state, current_screen: Screen::MethodList, selected_method: None, param_inputs: vec![], current_param_index: 0, response: None, error_message: None, }) } fn next_method(&mut self) { let i = match self.list_state.selected() { Some(i) => { if i >= self.methods.len() - 1 { 0 } else { i + 1 } } None => 0, }; self.list_state.select(Some(i)); } fn previous_method(&mut self) { let i = match self.list_state.selected() { Some(i) => { if i == 0 { self.methods.len() - 1 } else { i - 1 } } None => 0, }; self.list_state.select(Some(i)); } fn select_method(&mut self) { if let Some(i) = self.list_state.selected() { let method = self.methods[i].clone(); if method.params.is_empty() { // No parameters needed, call directly self.selected_method = Some(method); self.current_screen = Screen::Response; } else { // Parameters needed, go to input screen self.selected_method = Some(method.clone()); self.param_inputs = vec!["".to_string(); method.params.len()]; self.current_param_index = 0; self.current_screen = Screen::ParamInput; } } } fn next_param(&mut self) { if let Some(method) = &self.selected_method { if self.current_param_index < method.params.len() - 1 { self.current_param_index += 1; } } } fn previous_param(&mut self) { if self.current_param_index > 0 { self.current_param_index -= 1; } } fn add_char_to_current_param(&mut self, c: char) { if self.current_param_index < self.param_inputs.len() { self.param_inputs[self.current_param_index].push(c); } } fn remove_char_from_current_param(&mut self) { if self.current_param_index < self.param_inputs.len() { self.param_inputs[self.current_param_index].pop(); } } async fn execute_method(&mut self) { if let Some(method) = &self.selected_method { self.error_message = None; self.response = None; // Build parameters let mut params = json!({}); if !method.params.is_empty() { for (i, param) in method.params.iter().enumerate() { let input = &self.param_inputs[i]; if input.is_empty() && param.required { self.error_message = Some(format!("Required parameter '{}' is empty", param.name)); return; } if !input.is_empty() { let value = match param.param_type.as_str() { "bool" => { match input.to_lowercase().as_str() { "true" | "1" | "yes" => json!(true), "false" | "0" | "no" => json!(false), _ => { self.error_message = Some(format!("Invalid boolean value for '{}': {}", param.name, input)); return; } } } "i32" | "i64" | "u32" | "u64" => { match input.parse::() { Ok(n) => json!(n), Err(_) => { self.error_message = Some(format!("Invalid number for '{}': {}", param.name, input)); return; } } } _ => json!(input), }; if method.name == "register_runner" { // Special handling for register_runner method match param.name.as_str() { "secret" => params["secret"] = value, "name" => params["name"] = value, "queue" => params["queue"] = value, _ => {} } } else if method.name == "run_job" { // Special handling for run_job method match param.name.as_str() { "secret" => params["secret"] = value, "job_id" => params["job_id"] = value, "runner_name" => params["runner_name"] = value, "payload" => params["payload"] = value, _ => {} } } else { params[¶m.name] = value; } } } } // Execute the method let result: Result = match method.name.as_str() { "list_runners" => { match self.client.list_runners().await { Ok(response) => { match serde_json::to_value(response) { Ok(value) => Ok(value), Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)), } }, Err(e) => Err(e), } } "get_all_runner_status" => { match self.client.get_all_runner_status().await { Ok(response) => { match serde_json::to_value(response) { Ok(value) => Ok(value), Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)), } }, Err(e) => Err(e), } } "start_all" => { match self.client.start_all().await { Ok(response) => { match serde_json::to_value(response) { Ok(value) => Ok(value), Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)), } }, Err(e) => Err(e), } } "get_all_status" => { match self.client.get_all_status().await { Ok(response) => { match serde_json::to_value(response) { Ok(value) => Ok(value), Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)), } }, Err(e) => Err(e), } } "stop_all" => { let force = params.get("force").and_then(|v| v.as_bool()).unwrap_or(false); match self.client.stop_all(force).await { Ok(response) => { match serde_json::to_value(response) { Ok(value) => Ok(value), Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)), } }, Err(e) => Err(e), } } "start_runner" => { if let Some(actor_id) = params.get("actor_id").and_then(|v| v.as_str()) { match self.client.start_runner(actor_id).await { Ok(response) => { match serde_json::to_value(response) { Ok(value) => Ok(value), Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)), } }, Err(e) => Err(e), } } else { Err(hero_supervisor_openrpc_client::ClientError::from( serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Missing actor_id parameter")) )) } } "stop_runner" => { if let (Some(actor_id), Some(force)) = ( params.get("actor_id").and_then(|v| v.as_str()), params.get("force").and_then(|v| v.as_bool()) ) { match self.client.stop_runner(actor_id, force).await { Ok(response) => { match serde_json::to_value(response) { Ok(value) => Ok(value), Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)), } }, Err(e) => Err(e), } } else { Err(hero_supervisor_openrpc_client::ClientError::from( serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Missing parameters")) )) } } "remove_runner" => { if let Some(actor_id) = params.get("actor_id").and_then(|v| v.as_str()) { match self.client.remove_runner(actor_id).await { Ok(response) => { match serde_json::to_value(response) { Ok(value) => Ok(value), Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)), } }, Err(e) => Err(e), } } else { Err(hero_supervisor_openrpc_client::ClientError::from( serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Missing actor_id parameter")) )) } } "get_runner_status" => { if let Some(actor_id) = params.get("actor_id").and_then(|v| v.as_str()) { match self.client.get_runner_status(actor_id).await { Ok(response) => { match serde_json::to_value(response) { Ok(value) => Ok(value), Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)), } }, Err(e) => Err(e), } } else { Err(hero_supervisor_openrpc_client::ClientError::from( serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Missing actor_id parameter")) )) } } "register_runner" => { if let (Some(secret), Some(name), Some(queue)) = ( params.get("secret").and_then(|v| v.as_str()), params.get("name").and_then(|v| v.as_str()), params.get("queue").and_then(|v| v.as_str()) ) { match self.client.register_runner(secret, name, queue).await { Ok(response) => { match serde_json::to_value(response) { Ok(value) => Ok(value), Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)), } }, Err(e) => Err(e), } } else { Err(hero_supervisor_openrpc_client::ClientError::from( serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Missing required parameters: secret, name, queue")) )) } } "run_job" => { if let (Some(secret), Some(job_id), Some(runner_name), Some(payload)) = ( params.get("secret").and_then(|v| v.as_str()), params.get("job_id").and_then(|v| v.as_str()), params.get("runner_name").and_then(|v| v.as_str()), params.get("payload").and_then(|v| v.as_str()) ) { // Create a job object let job = serde_json::json!({ "id": job_id, "caller_id": "cli_user", "context_id": "cli_context", "payload": payload, "job_type": "SAL", "runner_name": runner_name, "timeout": 30000000000u64, // 30 seconds in nanoseconds "env_vars": {}, "created_at": chrono::Utc::now().to_rfc3339(), "updated_at": chrono::Utc::now().to_rfc3339() }); match self.client.run_job(secret, job).await { Ok(response) => { match serde_json::to_value(response) { Ok(value) => Ok(value), Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)), } }, Err(e) => Err(e), } } else { Err(hero_supervisor_openrpc_client::ClientError::from( serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Missing required parameters: secret, job_id, runner_name, payload")) )) } } _ => Err(hero_supervisor_openrpc_client::ClientError::from( serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Method not implemented in CLI")) )), }; match result { Ok(response) => { self.response = Some(format!("{:#}", response)); } Err(e) => { self.error_message = Some(format!("Error: {}", e)); } } self.current_screen = Screen::Response; } } fn back_to_methods(&mut self) { self.current_screen = Screen::MethodList; self.selected_method = None; self.param_inputs.clear(); self.current_param_index = 0; self.response = None; self.error_message = None; } } fn ui(f: &mut Frame, app: &mut App) { match app.current_screen { Screen::MethodList => draw_method_list(f, app), Screen::ParamInput => draw_param_input(f, app), Screen::Response => draw_response(f, app), } } fn draw_method_list(f: &mut Frame, app: &mut App) { let chunks = Layout::default() .direction(Direction::Vertical) .margin(1) .constraints([Constraint::Min(0)].as_ref()) .split(f.area()); let items: Vec = app .methods .iter() .map(|method| { let content = vec![Line::from(vec![ Span::styled(&method.name, Style::default().fg(Color::Yellow)), Span::raw(" - "), Span::raw(&method.description), ])]; ListItem::new(content) }) .collect(); let items = List::new(items) .block( Block::default() .borders(Borders::ALL) .title("OpenRPC Methods (↑↓ to navigate, Enter to select, q to quit)"), ) .highlight_style( Style::default() .bg(Color::LightGreen) .fg(Color::Black) .add_modifier(Modifier::BOLD), ) .highlight_symbol(">> "); f.render_stateful_widget(items, chunks[0], &mut app.list_state); } fn draw_param_input(f: &mut Frame, app: &mut App) { if let Some(method) = &app.selected_method { let chunks = Layout::default() .direction(Direction::Vertical) .margin(1) .constraints([ Constraint::Length(3), Constraint::Min(0), Constraint::Length(3), ]) .split(f.area()); // Title let title = Paragraph::new(format!("Parameters for: {}", method.name)) .block(Block::default().borders(Borders::ALL).title("Method")); f.render_widget(title, chunks[0]); // Parameters - create proper form layout with separate label and input areas let param_chunks = Layout::default() .direction(Direction::Vertical) .constraints(vec![Constraint::Length(5); method.params.len()]) .split(chunks[1]); for (i, param) in method.params.iter().enumerate() { let is_current = i == app.current_param_index; // Split each parameter into label and input areas let param_layout = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(2), Constraint::Length(3)]) .split(param_chunks[i]); // Parameter label and description let label_style = if is_current { Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::White) }; let label_text = vec![ Line::from(vec![ Span::styled(¶m.name, label_style), Span::raw(if param.required { " (required)" } else { " (optional)" }), Span::raw(format!(" [{}]", param.param_type)), ]), Line::from(Span::styled(¶m.description, Style::default().fg(Color::Gray))), ]; let label_widget = Paragraph::new(label_text) .block(Block::default().borders(Borders::NONE)); f.render_widget(label_widget, param_layout[0]); // Input field let empty_string = String::new(); let input_value = app.param_inputs.get(i).unwrap_or(&empty_string); let input_display = if is_current { if input_value.is_empty() { "█".to_string() // Show cursor when active and empty } else { format!("{}█", input_value) // Show cursor at end when active } } else { if input_value.is_empty() { " ".to_string() // Empty space for inactive empty fields } else { input_value.clone() } }; let input_style = if is_current { Style::default().fg(Color::Black).bg(Color::Cyan) } else { Style::default().fg(Color::White).bg(Color::DarkGray) }; let border_style = if is_current { Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::Gray) }; let input_widget = Paragraph::new(Line::from(Span::styled(input_display, input_style))) .block( Block::default() .borders(Borders::ALL) .border_style(border_style) .title(if is_current { " INPUT " } else { "" }), ); f.render_widget(input_widget, param_layout[1]); } // Instructions let instructions = Paragraph::new("↑↓ to navigate params, type to edit, Enter to execute, Esc to go back") .block(Block::default().borders(Borders::ALL).title("Instructions")); f.render_widget(instructions, chunks[2]); } } fn draw_response(f: &mut Frame, app: &mut App) { let chunks = Layout::default() .direction(Direction::Vertical) .margin(1) .constraints([ Constraint::Length(3), Constraint::Min(0), Constraint::Length(3), ]) .split(f.area()); // Title let method_name = app.selected_method.as_ref().map(|m| m.name.as_str()).unwrap_or("Unknown"); let title = Paragraph::new(format!("Response for: {}", method_name)) .block(Block::default().borders(Borders::ALL).title("Response")); f.render_widget(title, chunks[0]); // Response content let content = if let Some(error) = &app.error_message { Text::from(error.clone()).style(Style::default().fg(Color::Red)) } else if let Some(response) = &app.response { Text::from(response.clone()).style(Style::default().fg(Color::Green)) } else { Text::from("Executing...").style(Style::default().fg(Color::Yellow)) }; let response_widget = Paragraph::new(content) .block(Block::default().borders(Borders::ALL)) .wrap(Wrap { trim: true }); f.render_widget(response_widget, chunks[1]); // Instructions let instructions = Paragraph::new("Esc to go back to methods") .block(Block::default().borders(Borders::ALL).title("Instructions")); f.render_widget(instructions, chunks[2]); } #[tokio::main] async fn main() -> Result<(), Box> { let cli = Cli::parse(); // Setup terminal enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; // Create app let mut app = match App::new(cli.url).await { Ok(app) => app, Err(e) => { // Cleanup terminal before showing error disable_raw_mode()?; execute!( terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture )?; terminal.show_cursor()?; eprintln!("Failed to connect to OpenRPC server: {}", e); eprintln!("Make sure the supervisor is running with OpenRPC enabled."); std::process::exit(1); } }; // Main loop loop { terminal.draw(|f| ui(f, &mut app))?; if let Event::Key(key) = event::read()? { if key.kind == KeyEventKind::Press { match app.current_screen { Screen::MethodList => { match key.code { KeyCode::Char('q') => break, KeyCode::Down => app.next_method(), KeyCode::Up => app.previous_method(), KeyCode::Enter => { app.select_method(); // If the selected method has no parameters, execute it immediately if let Some(method) = &app.selected_method { if method.params.is_empty() { app.execute_method().await; } } }, _ => {} } } Screen::ParamInput => { match key.code { KeyCode::Esc => app.back_to_methods(), KeyCode::Up => app.previous_param(), KeyCode::Down => app.next_param(), KeyCode::Enter => { app.execute_method().await; } KeyCode::Backspace => app.remove_char_from_current_param(), KeyCode::Char(c) => app.add_char_to_current_param(c), _ => {} } } Screen::Response => { match key.code { KeyCode::Esc => app.back_to_methods(), _ => {} } } } } } } // Restore terminal disable_raw_mode()?; execute!( terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture )?; terminal.show_cursor()?; Ok(()) }