feat: add colibri task commands

This commit is contained in:
Sam & Claude 2026-05-27 22:23:08 +02:00
parent 12596d1a71
commit eaa2f27680
3 changed files with 317 additions and 0 deletions

View file

@ -30,6 +30,18 @@ enum Command {
CompactSession {
session_id: String,
},
ListTasks {
status: Option<String>,
},
CreateTask {
title: String,
description: Option<String>,
},
IntakeTask {
title: String,
description: Option<String>,
capabilities: Vec<String>,
},
}
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<Command, String> {
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>), 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<String>, Vec<String>), 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<String>, Option<String>), 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();

View file

@ -141,6 +141,39 @@ impl DaemonClient {
})
.await
}
pub async fn list_tasks(
&self,
status: Option<String>,
) -> Result<serde_json::Value, ClientError> {
self.request(&HerdrCommand::ListTasks { status }).await
}
pub async fn create_task(
&self,
title: impl Into<String>,
description: Option<String>,
) -> Result<serde_json::Value, ClientError> {
self.request(&HerdrCommand::CreateTask {
title: title.into(),
description,
})
.await
}
pub async fn intake_task(
&self,
title: impl Into<String>,
description: Option<String>,
capabilities: Vec<String>,
) -> Result<serde_json::Value, ClientError> {
self.request(&HerdrCommand::IntakeTask {
title: title.into(),
description,
capabilities: Some(capabilities),
})
.await
}
}
#[cfg(test)]

View file

@ -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<String> = 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();