From eaa2f27680cbdba62f2d52de217ff4a97a14abe9 Mon Sep 17 00:00:00 2001 From: Sam & Claude Date: Wed, 27 May 2026 22:23:08 +0200 Subject: [PATCH] feat: add colibri task commands --- crates/colibri-client/src/bin/colibri.rs | 201 ++++++++++++++++++ crates/colibri-client/src/lib.rs | 33 +++ .../colibri-client/tests/live_socket_smoke.rs | 83 ++++++++ 3 files changed, 317 insertions(+) diff --git a/crates/colibri-client/src/bin/colibri.rs b/crates/colibri-client/src/bin/colibri.rs index 6d27dbd..1abe9b4 100644 --- a/crates/colibri-client/src/bin/colibri.rs +++ b/crates/colibri-client/src/bin/colibri.rs @@ -30,6 +30,18 @@ enum Command { CompactSession { session_id: String, }, + ListTasks { + status: Option, + }, + CreateTask { + title: String, + description: Option, + }, + IntakeTask { + title: String, + description: Option, + capabilities: Vec, + }, } fn default_socket_path() -> PathBuf { @@ -46,6 +58,9 @@ fn usage() -> &'static str { colibri [--socket PATH] kill AGENT_ID colibri [--socket PATH] get-session SESSION_ID colibri [--socket PATH] compact-session SESSION_ID + colibri [--socket PATH] list-tasks [--status STATUS] + colibri [--socket PATH] create-task --title TEXT [--description TEXT] + colibri [--socket PATH] intake-task --title TEXT [--description TEXT] [--capability CAP]... Socket defaults to COLIBRI_DAEMON_SOCKET, then the daemon's configured default. @@ -53,6 +68,9 @@ Examples: colibri status colibri --socket /tmp/colibri-smoke/colibri.sock snapshot colibri spawn-local target/release/colibri-smoke-agent + colibri create-task --title "verify OSA smoke" --description "manual follow-up" + colibri intake-task --title "triage watchdog" --capability freebsd + colibri list-tasks --status queued "# } @@ -124,6 +142,19 @@ where "compact-session" => expect_arity(&args, 2).map(|()| Command::CompactSession { session_id: args[1].clone(), }), + "list-tasks" => parse_list_tasks_options(&args[1..]), + "create-task" => { + let (title, description) = parse_task_text_options("create-task", &args[1..])?; + Ok(Command::CreateTask { title, description }) + } + "intake-task" => { + let (title, description, capabilities) = parse_intake_task_options(&args[1..])?; + Ok(Command::IntakeTask { + title, + description, + capabilities, + }) + } other => Err(format!("unknown command: {other}\n\n{}", usage())), }?; @@ -145,6 +176,109 @@ fn expect_arity(args: &[String], expected: usize) -> Result<(), String> { } } +fn parse_list_tasks_options(args: &[String]) -> Result { + let mut status = None; + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--status" => { + let Some(value) = args.get(i + 1) else { + return Err("--status requires STATUS\n\n".to_string() + usage()); + }; + status = Some(value.clone()); + i += 2; + } + other => return Err(format!("unknown list-tasks option: {other}\n\n{}", usage())), + } + } + Ok(Command::ListTasks { status }) +} + +fn parse_task_text_options( + command: &str, + args: &[String], +) -> Result<(String, Option), String> { + let mut title = None; + let mut description = None; + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--title" => { + let Some(value) = args.get(i + 1) else { + return Err("--title requires TEXT\n\n".to_string() + usage()); + }; + title = Some(value.clone()); + i += 2; + } + "--description" => { + let Some(value) = args.get(i + 1) else { + return Err("--description requires TEXT\n\n".to_string() + usage()); + }; + description = Some(value.clone()); + i += 2; + } + other => return Err(format!("unknown {command} option: {other}\n\n{}", usage())), + } + } + let title = title.ok_or_else(|| format!("{command} requires --title TEXT\n\n{}", usage()))?; + Ok((title, description)) +} + +fn parse_intake_task_options( + args: &[String], +) -> Result<(String, Option, Vec), String> { + let mut title = None; + let mut description = None; + let mut capabilities = Vec::new(); + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--title" => { + let Some(value) = args.get(i + 1) else { + return Err("--title requires TEXT\n\n".to_string() + usage()); + }; + title = Some(value.clone()); + i += 2; + } + "--description" => { + let Some(value) = args.get(i + 1) else { + return Err("--description requires TEXT\n\n".to_string() + usage()); + }; + description = Some(value.clone()); + i += 2; + } + "--capability" => { + let Some(value) = args.get(i + 1) else { + return Err("--capability requires CAP\n\n".to_string() + usage()); + }; + capabilities.push(value.clone()); + i += 2; + } + "--capabilities" => { + let Some(value) = args.get(i + 1) else { + return Err("--capabilities requires CSV\n\n".to_string() + usage()); + }; + capabilities.extend( + value + .split(',') + .map(str::trim) + .filter(|cap| !cap.is_empty()) + .map(ToString::to_string), + ); + i += 2; + } + other => { + return Err(format!( + "unknown intake-task option: {other}\n\n{}", + usage() + )) + } + } + } + let title = title.ok_or_else(|| format!("intake-task requires --title TEXT\n\n{}", usage()))?; + Ok((title, description, capabilities)) +} + fn parse_spawn_options(args: &[String]) -> Result<(Option, Option), String> { let mut session_id = None; let mut system_prompt = None; @@ -192,6 +326,15 @@ async fn run(options: Options) -> Result<(), ClientError> { Command::CompactSession { session_id } => { print_json(&client.compact_session(session_id).await?) } + Command::ListTasks { status } => print_json(&client.list_tasks(status).await?), + Command::CreateTask { title, description } => { + print_json(&client.create_task(title, description).await?) + } + Command::IntakeTask { + title, + description, + capabilities, + } => print_json(&client.intake_task(title, description, capabilities).await?), } } @@ -265,6 +408,64 @@ mod tests { ); } + #[test] + fn parses_task_commands() { + assert_eq!( + parsed(&["list-tasks", "--status", "queued"]), + Options { + socket_path: default_socket_path(), + command: Command::ListTasks { + status: Some("queued".to_string()), + }, + } + ); + assert_eq!( + parsed(&[ + "create-task", + "--title", + "check smoke", + "--description", + "from cli", + ]), + Options { + socket_path: default_socket_path(), + command: Command::CreateTask { + title: "check smoke".to_string(), + description: Some("from cli".to_string()), + }, + } + ); + assert_eq!( + parsed(&[ + "intake-task", + "--title", + "triage", + "--capability", + "freebsd", + "--capabilities", + "sqlite,scheduler", + ]), + Options { + socket_path: default_socket_path(), + command: Command::IntakeTask { + title: "triage".to_string(), + description: None, + capabilities: vec![ + "freebsd".to_string(), + "sqlite".to_string(), + "scheduler".to_string(), + ], + }, + } + ); + } + + #[test] + fn rejects_create_task_without_title() { + let err = parse_args(["create-task", "--description", "missing title"]).unwrap_err(); + assert!(err.contains("create-task requires --title")); + } + #[test] fn rejects_unknown_command() { let err = parse_args(["bogus"]).unwrap_err(); diff --git a/crates/colibri-client/src/lib.rs b/crates/colibri-client/src/lib.rs index a169775..b82fe9a 100644 --- a/crates/colibri-client/src/lib.rs +++ b/crates/colibri-client/src/lib.rs @@ -141,6 +141,39 @@ impl DaemonClient { }) .await } + + pub async fn list_tasks( + &self, + status: Option, + ) -> Result { + self.request(&HerdrCommand::ListTasks { status }).await + } + + pub async fn create_task( + &self, + title: impl Into, + description: Option, + ) -> Result { + self.request(&HerdrCommand::CreateTask { + title: title.into(), + description, + }) + .await + } + + pub async fn intake_task( + &self, + title: impl Into, + description: Option, + capabilities: Vec, + ) -> Result { + self.request(&HerdrCommand::IntakeTask { + title: title.into(), + description, + capabilities: Some(capabilities), + }) + .await + } } #[cfg(test)] diff --git a/crates/colibri-client/tests/live_socket_smoke.rs b/crates/colibri-client/tests/live_socket_smoke.rs index 0c0ab97..be9bd90 100644 --- a/crates/colibri-client/tests/live_socket_smoke.rs +++ b/crates/colibri-client/tests/live_socket_smoke.rs @@ -1,4 +1,5 @@ use std::{ + process::Command, sync::Arc, time::{Duration, Instant}, }; @@ -42,6 +43,30 @@ async fn wait_for_socket(client: &DaemonClient) { } } +async fn run_colibri_cli(socket_path: &std::path::Path, args: &[&str]) -> serde_json::Value { + let bin = env!("CARGO_BIN_EXE_colibri"); + let socket_path = socket_path.to_path_buf(); + let args: Vec = args.iter().map(|arg| arg.to_string()).collect(); + let output = tokio::task::spawn_blocking(move || { + Command::new(bin) + .arg("--socket") + .arg(socket_path) + .args(args) + .output() + .expect("run colibri CLI") + }) + .await + .expect("join colibri CLI task"); + + assert!( + output.status.success(), + "colibri CLI failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).expect("parse colibri CLI JSON output") +} + async fn wait_for_state(client: &DaemonClient, expected: AgentState) -> String { let deadline = Instant::now() + Duration::from_secs(8); loop { @@ -111,6 +136,64 @@ async fn daemon_client_live_socket_smoke_with_local_fake_agent() { let _ = tokio::fs::remove_dir_all(config.data_dir).await; } +#[tokio::test] +async fn colibri_cli_task_commands_use_socket_api() { + let config = smoke_config(); + tokio::fs::create_dir_all(&config.data_dir).await.unwrap(); + + let state: SharedState = Arc::new(DaemonState::new(config.clone())); + let shutdown = state.shutdown_rx.resubscribe(); + let server_state = state.clone(); + let server = tokio::spawn(async move { + socket::serve(server_state, shutdown).await; + }); + + let client = DaemonClient::new(config.socket_path.clone()); + wait_for_socket(&client).await; + + let created = run_colibri_cli( + &config.socket_path, + &[ + "create-task", + "--title", + "cli-created-task", + "--description", + "created through the colibri binary", + ], + ) + .await; + assert_eq!(created["title"], "cli-created-task"); + assert_eq!(created["status"], "queued"); + + let tasks = run_colibri_cli(&config.socket_path, &["list-tasks", "--status", "queued"]).await; + assert!(tasks + .as_array() + .unwrap() + .iter() + .any(|task| task["title"] == "cli-created-task")); + + let intake = run_colibri_cli( + &config.socket_path, + &[ + "intake-task", + "--title", + "cli-intake-task", + "--description", + "queued through the scheduler intake", + "--capability", + "freebsd", + "--capabilities", + "sqlite,scheduler", + ], + ) + .await; + assert_eq!(intake["status"], "queued"); + + let _ = state.shutdown_tx.send(()); + server.await.unwrap(); + let _ = tokio::fs::remove_dir_all(config.data_dir).await; +} + #[tokio::test] async fn harness_double_spawn_session_isolation() { let config = smoke_config();