//! Makima CLI - unified CLI for server, daemon, and task management.
use std::io::{self, Read};
use std::path::Path;
use std::sync::Arc;
use makima::daemon::api::ApiClient;
use makima::daemon::cli::{
Cli, CliConfig, Commands, ConfigCommand, DirectiveCommand, ViewArgs,
};
use makima::daemon::tui::{self, Action, App, ListItem, ViewType, TuiWsClient, WsEvent, OutputLine, OutputMessageType, WsConnectionState, RepositorySuggestion};
use makima::daemon::config::{DaemonConfig, RepoEntry};
use makima::daemon::db::LocalDb;
use makima::daemon::error::DaemonError;
use makima::daemon::setup;
use makima::daemon::task::{TaskConfig, TaskManager};
use makima::daemon::ws::{DaemonCommand, WsClient};
use tokio::process::Command;
use tokio::sync::mpsc;
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let cli = Cli::parse_args();
match cli.command {
Commands::Server(args) => run_server(args).await,
Commands::Daemon(args) => run_daemon(args).await,
Commands::Directive(cmd) => run_directive(cmd).await,
Commands::View(args) => run_view(args).await,
Commands::Config(cmd) => run_config(cmd).await,
}
}
/// Run the makima server.
async fn run_server(
args: makima::daemon::cli::ServerArgs,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Initialize logging
init_logging(&args.log_level, "text");
eprintln!("=== Makima Server Starting ===");
eprintln!("Port: {}", args.port);
// Create app state. ML model paths are optional now — when none of
// them are supplied (the slim Dockerfile case) Listen and Speak
// websocket endpoints respond with "not configured" and everything
// else works normally.
let mut app_state = match (
args.parakeet_model_dir.as_deref(),
args.parakeet_eou_dir.as_deref(),
args.sortformer_model_path.as_deref(),
args.chatterbox_model_dir.as_deref(),
) {
(Some(p), Some(eou), Some(sf), Some(cb)) => {
eprintln!("ML models configured (lazy load on first use)");
makima::server::state::AppState::new(p, eou, sf, cb)
}
(None, None, None, None) => {
eprintln!("ML models NOT configured — Listen/Speak disabled");
makima::server::state::AppState::new_slim()
}
_ => {
eprintln!(
"WARNING: only some ML model paths provided. Pass all four \
(parakeet/parakeet_eou/sortformer/chatterbox) to enable ML, \
or none to run in slim mode. Continuing in slim mode."
);
makima::server::state::AppState::new_slim()
}
};
// Connect to database if URL provided
if let Some(ref db_url) = args.database_url {
eprintln!("Connecting to database...");
let pool = makima::db::create_pool(db_url).await?;
app_state = app_state.with_db_pool(pool);
eprintln!("Database connected");
}
let state = Arc::new(app_state);
let addr = format!("0.0.0.0:{}", args.port);
eprintln!("Starting server on {}", addr);
makima::server::run_server(state, &addr).await?;
Ok(())
}
/// Run the makima daemon.
async fn run_daemon(
args: makima::daemon::cli::DaemonArgs,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
eprintln!("=== Makima Daemon Starting ===");
// Check dependencies unless skipped
if !args.skip_setup_check {
eprintln!("[0/5] Checking dependencies...");
let dep_result = setup::check_dependencies().await;
setup::print_dependency_summary(&dep_result);
// Check for missing critical dependencies
if !dep_result.claude.installed {
let os = setup::OperatingSystem::detect();
setup::print_claude_install_instructions(os);
std::process::exit(1);
}
if !dep_result.git.installed {
let os = setup::OperatingSystem::detect();
setup::print_git_install_instructions(os);
std::process::exit(1);
}
// Print git authentication warnings (non-fatal)
setup::print_git_auth_warnings(&dep_result);
}
// Install Claude Code skills for makima commands
eprintln!("[0.5/5] Installing Claude Code skills...");
if let Err(e) = makima::daemon::skill_installer::install_skills().await {
eprintln!(" WARNING: Failed to install skills: {}", e);
// Non-fatal: continue even if skill installation fails
}
// Build a temporary CLI struct for config loading
let cli = makima::daemon::cli::daemon::DaemonArgs {
config: args.config,
repos_dir: args.repos_dir,
worktrees_dir: args.worktrees_dir,
server_url: args.server_url,
api_key: args.api_key,
max_tasks: args.max_tasks,
log_level: args.log_level,
bubblewrap: args.bubblewrap,
skip_setup_check: args.skip_setup_check,
};
// Load configuration with CLI overrides
eprintln!("[1/5] Loading configuration...");
let config = match DaemonConfig::load_with_daemon_args(&cli) {
Ok(cfg) => {
eprintln!(" Config loaded: server={}", cfg.server.url);
cfg
}
Err(e) => {
eprintln!("Failed to load configuration: {}", e);
eprintln!();
eprintln!("Use CLI flags:");
eprintln!(" makima daemon --server-url ws://localhost:8080 --api-key your-api-key");
eprintln!();
eprintln!("Or set environment variables:");
eprintln!(" MAKIMA_DAEMON_SERVER_URL=ws://localhost:8080");
eprintln!(" MAKIMA_API_KEY=your-api-key");
eprintln!();
eprintln!("Or create a config file: makima-daemon.toml");
std::process::exit(1);
}
};
// Initialize logging
init_logging(&config.logging.level, &config.logging.format);
eprintln!("[2/5] Logging initialized");
// Initialize local database
eprintln!(
"[3/5] Opening local database: {}",
config.local_db.path.display()
);
let local_db = Arc::new(std::sync::Mutex::new(LocalDb::open(&config.local_db.path)?));
eprintln!(" Database opened");
// Initialize worktree directories
eprintln!("[4/5] Setting up directories...");
tokio::fs::create_dir_all(&config.worktree.base_dir).await?;
tokio::fs::create_dir_all(&config.worktree.repos_dir).await?;
tokio::fs::create_dir_all(&config.repos.home_dir).await?;
eprintln!(
" Worktree base: {}",
config.worktree.base_dir.display()
);
eprintln!(" Repos cache: {}", config.worktree.repos_dir.display());
eprintln!(" Home dir: {}", config.repos.home_dir.display());
// Auto-clone repositories if configured
if !config.repos.auto_clone.is_empty() {
eprintln!(
" Auto-cloning {} repositories...",
config.repos.auto_clone.len()
);
for repo_entry in &config.repos.auto_clone {
if let Err(e) = auto_clone_repo(repo_entry, &config.repos.home_dir).await {
eprintln!(" WARNING: Failed to clone {}: {}", repo_entry.url(), e);
}
}
}
// Create channels for communication
let (command_tx, mut command_rx) = mpsc::channel::<DaemonCommand>(64);
// Get machine info
let machine_id = get_machine_id();
let hostname = get_hostname();
eprintln!(" Machine ID: {}", machine_id);
eprintln!(" Hostname: {}", hostname);
// Create WebSocket client
eprintln!("[5/5] Connecting to server: {}", config.server.url);
let mut ws_client = WsClient::new(
config.server.clone(),
machine_id,
hostname,
config.process.max_concurrent_tasks as i32,
command_tx,
);
// Get sender for task manager
let ws_tx = ws_client.sender();
// Create task configuration
let bubblewrap_config = if config.process.bubblewrap.enabled {
Some(config.process.bubblewrap.clone())
} else {
None
};
// Derive HTTP API URL from WebSocket server URL (wss://... -> https://...)
let api_url = config
.server
.url
.replace("wss://", "https://")
.replace("ws://", "http://");
let task_config = TaskConfig {
max_concurrent_tasks: config.process.max_concurrent_tasks,
max_tasks_per_contract: config.process.max_tasks_per_contract,
worktree_base_dir: config.worktree.base_dir.clone(),
env_vars: config.process.env_vars.clone(),
claude_command: config.process.claude_command.clone(),
claude_args: config.process.claude_args.clone(),
claude_pre_args: config.process.claude_pre_args.clone(),
enable_permissions: config.process.enable_permissions,
disable_verbose: config.process.disable_verbose,
bubblewrap: bubblewrap_config,
api_url,
api_key: config.server.api_key.clone(),
heartbeat_commit_interval_secs: config.process.heartbeat_commit_interval_secs,
checkpoint_patches: config.process.checkpoint_patches.clone(),
};
// Create task manager with local database for crash recovery
let task_manager = Arc::new(TaskManager::new(task_config, ws_tx.clone(), local_db));
// Recover any orphaned tasks from previous daemon run
let recovered = task_manager.recover_orphaned_tasks().await;
if !recovered.is_empty() {
eprintln!(" Recovered {} orphaned tasks with intact worktrees", recovered.len());
}
// Spawn command handler
let task_manager_clone = task_manager.clone();
tokio::spawn(async move {
tracing::info!("Command handler started, waiting for commands...");
while let Some(command) = command_rx.recv().await {
tracing::info!("Received command from channel: {:?}", command);
if let Err(e) = task_manager_clone.handle_command(command).await {
tracing::error!("Failed to handle command: {}", e);
}
}
tracing::info!("Command handler stopped");
});
// Spawn periodic worktree health check (every 60 seconds)
let health_check_manager = task_manager.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
loop {
interval.tick().await;
let affected = health_check_manager.check_worktree_health().await;
if !affected.is_empty() {
tracing::info!(
count = affected.len(),
"Worktree health check detected missing worktrees - tasks marked for retry"
);
}
}
});
// Handle shutdown signals
let shutdown_signal = async {
tokio::signal::ctrl_c()
.await
.expect("Failed to install Ctrl+C handler");
eprintln!("\nReceived shutdown signal");
};
eprintln!("=== Daemon running (Ctrl+C to stop) ===");
// Run WebSocket client with shutdown handling
tokio::select! {
result = ws_client.run() => {
match result {
Ok(()) => eprintln!("WebSocket client exited cleanly"),
Err(DaemonError::AuthFailed(msg)) => {
eprintln!("ERROR: Authentication failed: {}", msg);
std::process::exit(1);
}
Err(e) => {
eprintln!("ERROR: WebSocket client error: {}", e);
std::process::exit(1);
}
}
}
_ = shutdown_signal => {
eprintln!("Shutting down...");
}
}
// Gracefully shutdown all running Claude processes
eprintln!("Terminating Claude processes...");
task_manager
.shutdown_all_processes(std::time::Duration::from_secs(5))
.await;
// Cleanup
tracing::info!("Daemon stopped");
Ok(())
}
/// Run directive commands.
async fn run_directive(
cmd: DirectiveCommand,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use makima::daemon::api::directive::*;
match cmd {
DirectiveCommand::List(args) => {
let client = ApiClient::new(args.api_url, args.api_key)?;
let result = client.list_directives().await?;
println!("{}", serde_json::to_string(&result.0)?);
}
DirectiveCommand::Get(args) | DirectiveCommand::Status(args) => {
let client = ApiClient::new(args.api_url, args.api_key)?;
let result = client.get_directive(args.directive_id).await?;
println!("{}", serde_json::to_string(&result.0)?);
}
DirectiveCommand::AddStep(args) => {
let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
let depends_on: Vec<uuid::Uuid> = args
.depends_on
.map(|d| {
d.split(',')
.filter_map(|s| uuid::Uuid::parse_str(s.trim()).ok())
.collect()
})
.unwrap_or_default();
let req = CreateStepRequest {
name: args.name,
description: args.description,
task_plan: args.task_plan,
depends_on,
order_index: args.order_index,
};
let result = client.directive_add_step(args.common.directive_id, req).await?;
println!("{}", serde_json::to_string(&result.0)?);
}
DirectiveCommand::RemoveStep(args) => {
let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
client.directive_remove_step(args.common.directive_id, args.step_id).await?;
println!(r#"{{"success": true}}"#);
}
DirectiveCommand::SetDeps(args) => {
let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
let depends_on: Vec<uuid::Uuid> = args
.depends_on
.split(',')
.filter_map(|s| uuid::Uuid::parse_str(s.trim()).ok())
.collect();
let result = client
.directive_set_deps(args.common.directive_id, args.step_id, depends_on)
.await?;
println!("{}", serde_json::to_string(&result.0)?);
}
DirectiveCommand::Start(args) => {
let client = ApiClient::new(args.api_url, args.api_key)?;
let result = client.directive_start(args.directive_id).await?;
println!("{}", serde_json::to_string(&result.0)?);
}
DirectiveCommand::Pause(args) => {
let client = ApiClient::new(args.api_url, args.api_key)?;
let result = client.directive_pause(args.directive_id).await?;
println!("{}", serde_json::to_string(&result.0)?);
}
DirectiveCommand::Advance(args) => {
let client = ApiClient::new(args.api_url, args.api_key)?;
let result = client.directive_advance(args.directive_id).await?;
println!("{}", serde_json::to_string(&result.0)?);
}
DirectiveCommand::CompleteStep(args) => {
let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
let result = client
.directive_complete_step(args.common.directive_id, args.step_id)
.await?;
println!("{}", serde_json::to_string(&result.0)?);
}
DirectiveCommand::FailStep(args) => {
let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
let result = client
.directive_fail_step(args.common.directive_id, args.step_id)
.await?;
println!("{}", serde_json::to_string(&result.0)?);
}
DirectiveCommand::SkipStep(args) => {
let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
let result = client
.directive_skip_step(args.common.directive_id, args.step_id)
.await?;
println!("{}", serde_json::to_string(&result.0)?);
}
DirectiveCommand::UpdateGoal(args) => {
let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
let result = client
.directive_update_goal(args.common.directive_id, &args.goal)
.await?;
println!("{}", serde_json::to_string(&result.0)?);
}
DirectiveCommand::BatchAddSteps(args) => {
let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
let steps: serde_json::Value = serde_json::from_str(&args.json)
.map_err(|e| format!("Invalid JSON: {}", e))?;
let result = client
.directive_batch_add_steps(args.common.directive_id, steps)
.await?;
println!("{}", serde_json::to_string(&result.0)?);
}
DirectiveCommand::Update(args) => {
let client = ApiClient::new(args.common.api_url, args.common.api_key)?;
let result = client
.directive_update(args.common.directive_id, args.pr_url, args.pr_branch, args.status)
.await?;
println!("{}", serde_json::to_string(&result.0)?);
}
DirectiveCommand::Ask(args) => {
let client = ApiClient::new(args.common.api_url.clone(), args.common.api_key.clone())?;
eprintln!("Asking user: {}...", args.question);
let choices = args
.choices
.map(|c| c.split(',').map(|s| s.trim().to_string()).collect())
.unwrap_or_default();
let result = client
.supervisor_ask(&args.question, choices, args.context, args.timeout, args.phaseguard, args.multi_select, args.non_blocking, args.question_type)
.await?;
let mut response_value = result.0;
// If the server returned still_pending, poll until we get a real response
while response_value.get("stillPending").and_then(|v| v.as_bool()).unwrap_or(false) {
// Extract question_id for polling
let question_id_str = response_value.get("questionId")
.and_then(|v| v.as_str())
.ok_or_else(|| {
Box::<dyn std::error::Error + Send + Sync>::from(
"Missing questionId in still_pending response"
)
})?;
let question_id: uuid::Uuid = question_id_str.parse().map_err(|e| {
Box::<dyn std::error::Error + Send + Sync>::from(
format!("Invalid questionId: {}", e)
)
})?;
eprintln!("Waiting for user response (polling)...");
let poll_result = client.supervisor_poll_question(question_id).await?;
response_value = poll_result.0;
}
println!("{}", serde_json::to_string(&response_value)?);
}
DirectiveCommand::CreateOrder(args) => {
// Validate order_type is spike or chore
if args.order_type != "spike" && args.order_type != "chore" {
eprintln!("Error: Only 'spike' and 'chore' order types are allowed. Got: '{}'", args.order_type);
std::process::exit(1);
}
let client = ApiClient::new(args.common.api_url.clone(), args.common.api_key.clone())?;
eprintln!("Creating order: {}...", args.title);
let labels = args
.labels
.map(|l| {
serde_json::Value::Array(
l.split(',')
.map(|s| serde_json::Value::String(s.trim().to_string()))
.collect(),
)
})
.unwrap_or_else(|| serde_json::json!([]));
let req = makima::daemon::api::supervisor::CreateOrderRequest {
title: args.title,
description: args.description,
priority: args.priority,
order_type: args.order_type,
labels,
repository_url: None,
};
let result = client.create_order(&req).await?;
println!("{}", serde_json::to_string(&result.0)?);
}
DirectiveCommand::Verify(args) => {
run_directive_verify(args).await?;
}
}
Ok(())
}
/// Run `makima directive verify` — checks that the current HEAD merges cleanly
/// into `<remote>/<base>`. Prints a JSON result and exits non-zero on conflict.
///
/// Implementation uses `git merge-tree --write-tree` (Git ≥ 2.38), which performs
/// the merge in-memory and lists conflicting paths without touching the working
/// tree or creating any commits.
async fn run_directive_verify(
args: makima::daemon::cli::directive::VerifyArgs,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use std::process::Command;
fn git(args: &[&str]) -> std::io::Result<std::process::Output> {
Command::new("git").args(args).output()
}
let head_ref = args.head.as_deref().unwrap_or("HEAD").to_string();
let base_ref = format!("{}/{}", args.remote, args.base);
if !args.skip_fetch {
eprintln!("Fetching {} {}...", args.remote, args.base);
let fetch = git(&["fetch", &args.remote, &args.base])?;
if !fetch.status.success() {
return Err(format!(
"git fetch {} {} failed: {}",
args.remote,
args.base,
String::from_utf8_lossy(&fetch.stderr)
)
.into());
}
}
let head_rev = {
let out = git(&["rev-parse", &head_ref])?;
if !out.status.success() {
return Err(format!(
"git rev-parse {} failed: {}",
head_ref,
String::from_utf8_lossy(&out.stderr)
)
.into());
}
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
let base_rev = {
let out = git(&["rev-parse", &base_ref])?;
if !out.status.success() {
return Err(format!(
"git rev-parse {} failed (did you fetch?): {}",
base_ref,
String::from_utf8_lossy(&out.stderr)
)
.into());
}
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
eprintln!("Verifying merge: {} ({}) <- {} ({})", base_ref, &base_rev[..7.min(base_rev.len())], head_ref, &head_rev[..7.min(head_rev.len())]);
let merge = Command::new("git")
.args(["merge-tree", "--write-tree", "--name-only", "--no-messages", &base_rev, &head_rev])
.output()?;
let stdout = String::from_utf8_lossy(&merge.stdout).to_string();
let stderr = String::from_utf8_lossy(&merge.stderr).to_string();
let success = merge.status.success();
let conflicting_files: Vec<String> = if success {
Vec::new()
} else {
stdout
.lines()
.skip(1)
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.collect()
};
let result = serde_json::json!({
"ok": success,
"base": base_ref,
"head": head_ref,
"baseSha": base_rev,
"headSha": head_rev,
"conflictingFiles": conflicting_files,
"goal": args.goal,
});
println!("{}", serde_json::to_string(&result)?);
if !success {
eprintln!("\n[FAIL] Merge would conflict in {} file(s):", conflicting_files.len());
for f in &conflicting_files {
eprintln!(" - {}", f);
}
if !stderr.is_empty() {
eprintln!("\ngit stderr:\n{}", stderr);
}
eprintln!(
"\nFix the conflicts before pushing. Typical workflow:\n \
git fetch {remote} {base}\n \
git merge {remote}/{base}\n \
# resolve conflicts, commit, then re-run `makima directive verify`",
remote = args.remote,
base = args.base,
);
std::process::exit(1);
}
if let Some(goal) = &args.goal {
eprintln!("\n[OK] No merge conflicts.");
eprintln!("Reminder — directive goal:\n {}\n", goal);
eprintln!("Confirm the diff (`git diff {}...HEAD`) actually delivers this goal before creating the PR.", base_ref);
} else {
eprintln!("[OK] No merge conflicts with {}.", base_ref);
}
Ok(())
}
/// Run the TUI view command.
async fn run_view(args: ViewArgs) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// 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(api_url.clone(), api_key.clone())?;
// Start WebSocket client for task output streaming
let ws_client = TuiWsClient::start(api_url, api_key);
// Start at contracts view
let mut app = App::new(ViewType::Contracts);
// Set initial search query if provided
if let Some(ref query) = args.query {
app.search_query = query.clone();
}
// Load initial contracts
let items = load_contracts(&client).await?;
app.set_items(items);
// Run TUI with navigation support
let result = run_tui_with_navigation(app, client, ws_client).await;
result
}
/// Run config commands.
async fn run_config(cmd: ConfigCommand) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
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<Vec<ListItem>, Box<dyn std::error::Error + Send + Sync>> {
let result = client.list_contracts().await?;
let items = result.0.get("contracts")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(ListItem::from_contract).collect())
.unwrap_or_default();
Ok(items)
}
/// Load tasks for a contract from API
async fn load_tasks(client: &ApiClient, contract_id: uuid::Uuid) -> Result<Vec<ListItem>, Box<dyn std::error::Error + Send + Sync>> {
// 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<ListItem> = 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)
}
/// Run the TUI with navigation support for drill-down views
async fn run_tui_with_navigation(
mut app: App,
client: ApiClient,
ws_client: TuiWsClient,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::backend::CrosstermBackend;
use std::io;
// Setup terminal
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = ratatui::Terminal::new(backend)?;
let result = run_tui_loop(&mut terminal, &mut app, &client, &ws_client).await;
// Cleanup WebSocket
ws_client.shutdown();
// Cleanup terminal
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
match result {
Ok(Some(path)) => {
// Output the path for shell integration
tui::print_path(&path);
}
Ok(None) => {
// Normal exit
}
Err(e) => {
eprintln!("TUI error: {}", e);
std::process::exit(1);
}
}
Ok(())
}
/// Main TUI event loop with async data loading
async fn run_tui_loop(
terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>,
app: &mut App,
client: &ApiClient,
ws_client: &TuiWsClient,
) -> Result<Option<String>, Box<dyn std::error::Error + Send + Sync>> {
use crossterm::event::{self, Event};
use std::time::Duration;
// Track currently subscribed task for cleanup
let mut subscribed_task_id: Option<uuid::Uuid> = None;
loop {
terminal.draw(|f| tui::ui::render(f, app))?;
// Process WebSocket events (non-blocking)
while let Some(ws_event) = ws_client.try_recv() {
handle_ws_event(app, ws_event);
}
// Poll for keyboard events with short timeout (50ms for responsive WS handling)
if event::poll(Duration::from_millis(50))? {
if let Event::Key(key) = event::read()? {
let action = tui::event::handle_key_event(app, key);
match action {
Action::Quit => break,
Action::OutputPath(path) => return Ok(Some(path)),
Action::None => {}
_ => {
let result = app.handle_action(action);
match result {
Action::OutputPath(path) => return Ok(Some(path)),
Action::LoadTasks { contract_id, contract_name: _ } => {
// Unsubscribe from any previous task
if let Some(task_id) = subscribed_task_id.take() {
ws_client.unsubscribe(task_id);
}
// Load tasks for the selected contract
match load_tasks(client, contract_id).await {
Ok(items) => {
app.set_items(items);
}
Err(e) => {
app.status_message = Some(format!("Failed to load tasks: {}", e));
}
}
}
Action::LoadTaskOutput { task_id, task_name: _ } => {
// Clear previous output
app.output_buffer.clear();
app.ws_state = WsConnectionState::Connecting;
// Unsubscribe from previous task if any
if let Some(old_task_id) = subscribed_task_id.take() {
ws_client.unsubscribe(old_task_id);
}
// 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);
}
Action::PerformDelete { id, item_type } => {
// Perform the delete API call
let delete_result = match item_type {
ViewType::Contracts => {
client.delete_contract(id).await
}
ViewType::Tasks => {
client.delete_task(id).await
}
ViewType::TaskOutput => {
// Can't delete from output view
Ok(())
}
};
match delete_result {
Ok(()) => {
app.status_message = Some("Deleted successfully".to_string());
// Remove item from list
app.items.retain(|item| item.id != id);
app.update_filtered_items();
}
Err(e) => {
app.status_message = Some(format!("Delete failed: {}", e));
}
}
}
Action::PerformUpdate { id, item_type, name, description } => {
// Perform the update API call
let update_result = match item_type {
ViewType::Contracts => {
client.update_contract(id, Some(name.clone()), Some(description.clone())).await.map(|_| ())
}
ViewType::Tasks => {
// For tasks, description is the plan
client.update_task(id, Some(name.clone()), Some(description.clone())).await.map(|_| ())
}
ViewType::TaskOutput => {
// Can't edit from output view
Ok(())
}
};
match update_result {
Ok(()) => {
app.status_message = Some("Updated successfully".to_string());
// Update item in list
for item in &mut app.items {
if item.id == id {
item.name = name.clone();
item.description = Some(description.clone());
break;
}
}
app.update_filtered_items();
}
Err(e) => {
app.status_message = Some(format!("Update failed: {}", e));
}
}
}
Action::Refresh => {
// Reload data for current view
match app.view_type {
ViewType::Contracts => {
// Unsubscribe from task when going back to contracts
if let Some(task_id) = subscribed_task_id.take() {
ws_client.unsubscribe(task_id);
}
match load_contracts(client).await {
Ok(items) => app.set_items(items),
Err(e) => app.status_message = Some(format!("Refresh failed: {}", e)),
}
}
ViewType::Tasks => {
// Unsubscribe from task when going back to tasks
if let Some(task_id) = subscribed_task_id.take() {
ws_client.unsubscribe(task_id);
}
if let Some(contract_id) = app.contract_id {
match load_tasks(client, contract_id).await {
Ok(items) => app.set_items(items),
Err(e) => app.status_message = Some(format!("Refresh failed: {}", e)),
}
}
}
ViewType::TaskOutput => {
// Re-subscribe to the task output
if let Some(task_id) = app.task_id {
app.output_buffer.clear();
app.ws_state = WsConnectionState::Connecting;
ws_client.subscribe(task_id);
subscribed_task_id = Some(task_id);
app.status_message = Some("Reconnecting...".to_string());
}
}
}
}
Action::GoBack => {
// Unsubscribe when going back from output view
if let Some(task_id) = subscribed_task_id.take() {
ws_client.unsubscribe(task_id);
app.ws_state = WsConnectionState::Disconnected;
}
}
Action::PerformCreateContract { name: _, description: _, contract_type: _, repository_url: _ } => {
// Contracts removed in Phase 5 — directives are
// the only way to organise multi-task work now.
// The TUI's contract create form is dead code
// pending a wider TUI refresh.
app.status_message = Some(
"Contracts have been removed. Use directives instead.".to_string()
);
}
Action::LoadRepoSuggestions => {
// Load repository suggestions for the create form
app.status_message = Some("Loading recent repositories...".to_string());
// Force a redraw to show the status
terminal.draw(|f| tui::ui::render(f, app)).ok();
// Fetch all repository types (remote and local)
match client.get_repository_suggestions(None, Some(10)).await {
Ok(result) => {
// Parse suggestions from API response
let suggestions: Vec<RepositorySuggestion> = result.0
.get("entries")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter().filter_map(|entry| {
let name = entry.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let repository_url = entry.get("repositoryUrl")
.or_else(|| entry.get("repository_url"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let local_path = entry.get("localPath")
.or_else(|| entry.get("local_path"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let source_type = entry.get("sourceType")
.or_else(|| entry.get("source_type"))
.and_then(|v| v.as_str())
.unwrap_or("remote")
.to_string();
let use_count = entry.get("useCount")
.or_else(|| entry.get("use_count"))
.and_then(|v| v.as_i64())
.unwrap_or(0) as i32;
// Only include if we have a URL or path
if repository_url.is_some() || local_path.is_some() {
Some(RepositorySuggestion {
name,
repository_url,
local_path,
source_type,
use_count,
})
} else {
None
}
}).collect()
})
.unwrap_or_default();
let count = suggestions.len();
app.create_state.set_suggestions(suggestions);
if count > 0 {
app.status_message = Some(format!("Found {} recent repositories", count));
} else {
app.status_message = Some("No recent repositories found".to_string());
}
}
Err(e) => {
app.status_message = Some(format!("Could not load suggestions: {}", e));
app.create_state.suggestions_loaded = true;
}
}
}
_ => {}
}
}
}
}
}
if app.should_quit {
break;
}
}
Ok(None)
}
/// Extract a repository name from a URL.
/// E.g., "https://github.com/owner/repo.git" -> "owner/repo"
fn extract_repo_name(url: &str) -> String {
// Remove .git suffix if present
let url = url.trim_end_matches(".git");
// Try to extract owner/repo from common Git hosting URLs
if let Some(path) = url.strip_prefix("https://github.com/")
.or_else(|| url.strip_prefix("https://gitlab.com/"))
.or_else(|| url.strip_prefix("https://bitbucket.org/"))
.or_else(|| url.strip_prefix("git@github.com:"))
.or_else(|| url.strip_prefix("git@gitlab.com:"))
.or_else(|| url.strip_prefix("git@bitbucket.org:"))
{
// Return owner/repo
return path.to_string();
}
// Fallback: try to get the last path segment
if let Some(last_segment) = url.rsplit('/').next() {
if !last_segment.is_empty() {
return last_segment.to_string();
}
}
// Last resort: use the full URL as the name
url.to_string()
}
/// Parse an output entry from the API response into an OutputLine
fn parse_output_entry(entry: &serde_json::Value) -> Option<OutputLine> {
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 {
WsEvent::Connected => {
app.ws_state = WsConnectionState::Connected;
app.status_message = Some("Connected".to_string());
}
WsEvent::Disconnected => {
app.ws_state = WsConnectionState::Disconnected;
app.status_message = Some("Disconnected".to_string());
}
WsEvent::Reconnecting { attempt } => {
app.ws_state = WsConnectionState::Reconnecting;
app.status_message = Some(format!("Reconnecting (attempt {})...", attempt));
}
WsEvent::Subscribed { task_id: _ } => {
app.ws_state = WsConnectionState::Connected;
app.status_message = Some("Subscribed to task output".to_string());
}
WsEvent::Unsubscribed { task_id: _ } => {
// No status update needed
}
WsEvent::TaskOutput(output) => {
// Convert WebSocket event to OutputLine
let line = OutputLine {
message_type: OutputMessageType::from_str(&output.message_type),
content: output.content,
tool_name: output.tool_name,
is_error: output.is_error.unwrap_or(false),
cost_usd: output.cost_usd,
duration_ms: output.duration_ms,
};
app.output_buffer.add_line(line);
// Clear status message once we're receiving output
if app.status_message.as_ref().map(|s| s.contains("Subscribed")).unwrap_or(false) {
app.status_message = None;
}
}
WsEvent::Error { message } => {
app.status_message = Some(format!("WS Error: {}", message));
}
}
}
fn init_logging(level: &str, format: &str) {
let filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new(level))
.unwrap_or_else(|_| EnvFilter::new("info"));
let subscriber = tracing_subscriber::registry().with(filter);
if format == "json" {
subscriber.with(fmt::layer().json()).init();
} else {
subscriber.with(fmt::layer()).init();
}
}
fn get_machine_id() -> String {
// Try to read machine-id from standard locations
#[cfg(target_os = "linux")]
{
if let Ok(id) = std::fs::read_to_string("/etc/machine-id") {
return id.trim().to_string();
}
if let Ok(id) = std::fs::read_to_string("/var/lib/dbus/machine-id") {
return id.trim().to_string();
}
}
#[cfg(target_os = "macos")]
{
// Use IOPlatformSerialNumber
if let Ok(output) = std::process::Command::new("ioreg")
.args(["-rd1", "-c", "IOPlatformExpertDevice"])
.output()
{
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if line.contains("IOPlatformUUID") {
if let Some(uuid) = line.split('"').nth(3) {
return uuid.to_string();
}
}
}
}
}
// Fallback: generate a random ID and persist it
let state_dir = dirs_next::data_local_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("makima");
let machine_id_file = state_dir.join("machine-id");
if let Ok(id) = std::fs::read_to_string(&machine_id_file) {
return id.trim().to_string();
}
// Generate new ID
let new_id = uuid::Uuid::new_v4().to_string();
std::fs::create_dir_all(&state_dir).ok();
std::fs::write(&machine_id_file, &new_id).ok();
new_id
}
fn get_hostname() -> String {
hostname::get()
.map(|h| h.to_string_lossy().to_string())
.unwrap_or_else(|_| "unknown".to_string())
}
/// Auto-clone a repository to the home directory if it doesn't exist.
async fn auto_clone_repo(
repo_entry: &RepoEntry,
home_dir: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let dir_name = repo_entry
.dir_name()
.ok_or("Could not determine directory name from URL")?;
let target_dir = home_dir.join(&dir_name);
// Check if already cloned
if target_dir.exists() {
eprintln!(" [skip] {} (already exists)", dir_name);
return Ok(());
}
let url = repo_entry.expanded_url();
eprintln!(" [clone] {} -> {}", url, target_dir.display());
// Build git clone command
let mut args = vec!["clone".to_string()];
// Add shallow clone if requested
if repo_entry.shallow() {
args.push("--depth".to_string());
args.push("1".to_string());
}
// Add branch if specified
if let Some(branch) = repo_entry.branch() {
args.push("--branch".to_string());
args.push(branch.to_string());
}
args.push(url.clone());
args.push(target_dir.to_string_lossy().to_string());
// Run git clone
let output = Command::new("git").args(&args).output().await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git clone failed: {}", stderr).into());
}
eprintln!(" [done] {}", dir_name);
Ok(())
}
/// dirs_next minimal replacement
mod dirs_next {
use std::path::PathBuf;
pub fn data_local_dir() -> Option<PathBuf> {
#[cfg(target_os = "macos")]
{
std::env::var("HOME")
.ok()
.map(|h| PathBuf::from(h).join("Library").join("Application Support"))
}
#[cfg(target_os = "linux")]
{
std::env::var("XDG_DATA_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| {
std::env::var("HOME")
.ok()
.map(|h| PathBuf::from(h).join(".local").join("share"))
})
}
#[cfg(target_os = "windows")]
{
std::env::var("LOCALAPPDATA").ok().map(PathBuf::from)
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
None
}
}
}