From 055e2c4a72e3b2331a18fdc9f8132ef990af7e38 Mon Sep 17 00:00:00 2001 From: soryu Date: Tue, 20 Jan 2026 17:23:34 +0000 Subject: Update CLI to show log history as well --- makima/src/bin/makima.rs | 212 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 204 insertions(+), 8 deletions(-) (limited to 'makima/src/bin') diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs index 8b3e4dc..37aa045 100644 --- a/makima/src/bin/makima.rs +++ b/makima/src/bin/makima.rs @@ -4,9 +4,9 @@ use std::io::{self, Read}; use std::path::Path; use std::sync::Arc; -use makima::daemon::api::ApiClient; +use makima::daemon::api::{ApiClient, CreateContractRequest}; use makima::daemon::cli::{ - Cli, Commands, ContractCommand, SupervisorCommand, ViewArgs, + Cli, CliConfig, Commands, ConfigCommand, ContractCommand, SupervisorCommand, ViewArgs, }; use makima::daemon::tui::{self, Action, App, ListItem, ViewType, TuiWsClient, WsEvent, OutputLine, OutputMessageType, WsConnectionState}; use makima::daemon::config::{DaemonConfig, RepoEntry}; @@ -28,6 +28,7 @@ async fn main() -> Result<(), Box> { Commands::Supervisor(cmd) => run_supervisor(cmd).await, Commands::Contract(cmd) => run_contract(cmd).await, Commands::View(args) => run_view(args).await, + Commands::Config(cmd) => run_config(cmd).await, } } @@ -559,11 +560,32 @@ async fn run_contract( /// Run the TUI view command. async fn run_view(args: ViewArgs) -> Result<(), Box> { + // Load CLI config for defaults + let config = CliConfig::load(); + + // Get API URL and key, preferring CLI args > env vars > config file + // Filter out empty strings + let api_url = args.api_url + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| config.get_api_url()); + let api_key = match args.api_key.filter(|s| !s.is_empty()) { + Some(key) => key, + None => config.get_api_key().ok_or_else(|| { + eprintln!("Error: No API key provided."); + eprintln!(); + eprintln!("Set your API key using one of these methods:"); + eprintln!(" 1. Run: makima config set-key YOUR_API_KEY"); + eprintln!(" 2. Set environment variable: export MAKIMA_API_KEY=YOUR_API_KEY"); + eprintln!(" 3. Pass via CLI: makima view --api-key YOUR_API_KEY"); + "No API key configured" + })?, + }; + // Create API client - let client = ApiClient::new(args.api_url.clone(), args.api_key.clone())?; + let client = ApiClient::new(api_url.clone(), api_key.clone())?; // Start WebSocket client for task output streaming - let ws_client = TuiWsClient::start(args.api_url.clone(), args.api_key.clone()); + let ws_client = TuiWsClient::start(api_url, api_key); // Start at contracts view let mut app = App::new(ViewType::Contracts); @@ -583,6 +605,49 @@ async fn run_view(args: ViewArgs) -> Result<(), Box Result<(), Box> { + match cmd { + ConfigCommand::SetKey(args) => { + let mut config = CliConfig::load(); + config.api_key = Some(args.api_key); + config.save()?; + println!("API key saved to {:?}", CliConfig::config_path().unwrap_or_default()); + Ok(()) + } + ConfigCommand::SetUrl(args) => { + let mut config = CliConfig::load(); + config.api_url = args.api_url; + config.save()?; + println!("API URL saved to {:?}", CliConfig::config_path().unwrap_or_default()); + Ok(()) + } + ConfigCommand::Show => { + let config = CliConfig::load(); + println!("Configuration:"); + println!(" API URL: {}", config.api_url); + println!(" API Key: {}", config.api_key.as_ref().map(|k| { + if k.len() > 10 { + format!("{}...{}", &k[..6], &k[k.len()-4..]) + } else { + "***".to_string() + } + }).unwrap_or_else(|| "(not set)".to_string())); + println!(); + println!("Config file: {:?}", CliConfig::config_path().unwrap_or_default()); + Ok(()) + } + ConfigCommand::Path => { + if let Some(path) = CliConfig::config_path() { + println!("{}", path.display()); + } else { + eprintln!("Could not determine config path"); + } + Ok(()) + } + } +} + /// Load contracts from API async fn load_contracts(client: &ApiClient) -> Result, Box> { let result = client.list_contracts().await?; @@ -595,10 +660,51 @@ async fn load_contracts(client: &ApiClient) -> Result, Box Result, Box> { - let result = client.supervisor_tasks(contract_id).await?; - let items = result.0.as_array() + // Use get_contract which returns tasks as part of the response (works with regular API key auth) + let result = client.get_contract(contract_id).await?; + let mut items: Vec = result.0.get("tasks") + .and_then(|v| v.as_array()) .map(|arr| arr.iter().filter_map(ListItem::from_task).collect()) .unwrap_or_default(); + + // Sort tasks: supervisor first, then by status (running first), then by name + items.sort_by(|a, b| { + // Check if task is supervisor (role field in extra data) + let a_is_supervisor = a.extra.get("role") + .and_then(|v| v.as_str()) + .map(|s| s == "supervisor") + .unwrap_or(false); + let b_is_supervisor = b.extra.get("role") + .and_then(|v| v.as_str()) + .map(|s| s == "supervisor") + .unwrap_or(false); + + // Supervisor first + match (a_is_supervisor, b_is_supervisor) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => { + // Then by status: running/working tasks first + let status_order = |s: Option<&String>| -> i32 { + match s.map(|x| x.as_str()) { + Some("running") | Some("working") => 0, + Some("pending") | Some("queued") => 1, + Some("completed") | Some("done") => 2, + Some("failed") | Some("error") => 3, + _ => 4, + } + }; + let a_order = status_order(a.status.as_ref()); + let b_order = status_order(b.status.as_ref()); + + match a_order.cmp(&b_order) { + std::cmp::Ordering::Equal => a.name.cmp(&b.name), + other => other, + } + } + } + }); + Ok(items) } @@ -712,10 +818,29 @@ async fn run_tui_loop( ws_client.unsubscribe(old_task_id); } - // Subscribe to new task output + // Load task output history first + app.status_message = Some("Loading output history...".to_string()); + match client.get_task_output(task_id).await { + Ok(result) => { + // Parse the entries array from response + if let Some(entries) = result.0.get("entries").and_then(|v| v.as_array()) { + for entry in entries { + if let Some(line) = parse_output_entry(entry) { + app.output_buffer.add_line(line); + } + } + let count = entries.len(); + app.status_message = Some(format!("Loaded {} history entries, streaming live...", count)); + } + } + Err(e) => { + app.status_message = Some(format!("Failed to load history: {}", e)); + } + } + + // Subscribe to new task output for live updates ws_client.subscribe(task_id); subscribed_task_id = Some(task_id); - app.status_message = Some("Connecting to task output...".to_string()); } Action::PerformDelete { id, item_type } => { // Perform the delete API call @@ -822,6 +947,47 @@ async fn run_tui_loop( app.ws_state = WsConnectionState::Disconnected; } } + Action::PerformCreateContract { name, description, contract_type, repository_url } => { + // Create the contract via API + let req = CreateContractRequest { + name: name.clone(), + description: if description.is_empty() { None } else { Some(description) }, + contract_type: Some(contract_type), + initial_phase: None, + autonomous_loop: None, + phase_guard: None, + }; + + match client.create_contract(req).await { + Ok(result) => { + let contract_name = result.0.get("name") + .and_then(|v| v.as_str()) + .unwrap_or(&name); + app.status_message = Some(format!("Created contract: {}", contract_name)); + + // Refresh the contracts list + match load_contracts(client).await { + Ok(items) => app.set_items(items), + Err(e) => app.status_message = Some(format!("Created but refresh failed: {}", e)), + } + + // TODO: If repository_url was provided, add it to the contract + if let Some(repo_url) = repository_url { + if !repo_url.is_empty() { + // We'd need to add a method to add repository to contract + // For now, just note it in the status + app.status_message = Some(format!( + "Created contract: {} (Note: Add repository {} manually)", + contract_name, repo_url + )); + } + } + } + Err(e) => { + app.status_message = Some(format!("Create failed: {}", e)); + } + } + } _ => {} } } @@ -837,6 +1003,36 @@ async fn run_tui_loop( Ok(None) } +/// Parse an output entry from the API response into an OutputLine +fn parse_output_entry(entry: &serde_json::Value) -> Option { + let message_type = entry.get("messageType") + .and_then(|v| v.as_str()) + .unwrap_or("raw"); + let content = entry.get("content") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let tool_name = entry.get("toolName") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let is_error = entry.get("isError") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let cost_usd = entry.get("costUsd") + .and_then(|v| v.as_f64()); + let duration_ms = entry.get("durationMs") + .and_then(|v| v.as_u64()); + + Some(OutputLine { + message_type: OutputMessageType::from_str(message_type), + content, + tool_name, + is_error, + cost_usd, + duration_ms, + }) +} + /// Handle a WebSocket event and update app state fn handle_ws_event(app: &mut App, event: WsEvent) { match event { -- cgit v1.2.3