#![cfg(all(feature = "comms", unix))]
use std::io::Write;
use std::path::Path;
use anyhow::{Context, Result};
use clap::Subcommand;
use serde_json::json;
use crate::comms::client::{CommsClient, scope_context_for};
use crate::comms::cursor::Cursor;
use crate::comms::ids::{AgentId, RoomId};
use crate::comms::model::{AgentCard, Room, RoomScope};
use crate::comms::protocol::SeqMeta;
const DEFAULT_LIMIT: u32 = 100;
const MAX_LIMIT: u32 = 1000;
#[derive(Subcommand, Debug)]
pub enum CommsAgentCmd {
Register {
#[arg(long)]
name: Option<String>,
#[arg(long)]
description: Option<String>,
#[arg(long)]
version: Option<String>,
#[arg(long = "skill")]
skills: Vec<String>,
},
Agents {
#[arg(long)]
room: Option<String>,
},
RoomCreate {
room: String,
#[arg(long, default_value = "global")]
scope: String,
#[arg(long)]
title: Option<String>,
},
Rooms,
Join {
room: String,
},
Leave {
room: String,
},
Post {
room: String,
subject: String,
#[arg(long)]
body: Option<String>,
#[arg(long = "tag")]
tags: Vec<String>,
#[arg(long)]
reply_to: Option<String>,
},
History {
room: String,
#[arg(long)]
cursor: Option<String>,
#[arg(long)]
limit: Option<u32>,
},
Read {
message_id: String,
},
Inbox {
#[arg(long)]
cursor: Option<String>,
#[arg(long)]
limit: Option<u32>,
#[arg(long)]
mark_read: bool,
},
}
fn parse_scope(raw: &str) -> Result<RoomScope> {
if raw == "global" {
return Ok(RoomScope::Global);
}
if let Some(url) = raw.strip_prefix("remote:") {
return Ok(RoomScope::Remote(url.to_string()));
}
if let Some(path) = raw.strip_prefix("path:") {
return Ok(RoomScope::PathPrefix(std::path::PathBuf::from(path)));
}
anyhow::bail!("invalid --scope {raw:?}: expected `global`, `remote:<url>`, or `path:<dir>`")
}
fn clamp_limit(limit: Option<u32>) -> u32 {
limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT)
}
fn cli_agent_id(root: &Path) -> Result<AgentId> {
if let Ok(raw) = std::env::var("BASEMIND_AGENT_ID")
&& let Ok(id) = AgentId::parse(raw)
{
return Ok(id);
}
if let Ok(existing) = std::fs::read_to_string(root.join(".basemind").join("agent-id"))
&& let Ok(id) = AgentId::parse(existing.trim())
{
return Ok(id);
}
AgentId::parse("basemind-cli").context("construct CLI agent id")
}
pub fn run(root: &Path, json: bool, cmd: CommsAgentCmd) -> Result<()> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("build tokio runtime")?;
runtime.block_on(async move {
let agent = cli_agent_id(root)?;
let (remote, cwd) = scope_context_for(root);
let mut client = CommsClient::ensure_and_connect(agent, remote.clone(), cwd.clone())
.await
.map_err(|e| anyhow::anyhow!("connect to comms daemon: {e}"))?;
let mut out = std::io::stdout().lock();
dispatch(&mut client, root, json, cmd, &mut out).await
})
}
async fn dispatch(
client: &mut CommsClient,
root: &Path,
json: bool,
cmd: CommsAgentCmd,
out: &mut impl Write,
) -> Result<()> {
match cmd {
CommsAgentCmd::Register {
name,
description,
version,
skills,
} => {
let card = AgentCard {
name: name.unwrap_or_default(),
description: description.unwrap_or_default(),
version: version.unwrap_or_default(),
skills,
};
let agent_id = client.agent().as_str().to_string();
client
.register_agent(card)
.await
.map_err(|e| anyhow::anyhow!("register: {e}"))?;
if json {
writeln!(
out,
"{}",
json!({ "agent_id": agent_id, "registered": true })
)?;
} else {
writeln!(out, "registered as {agent_id}")?;
}
}
CommsAgentCmd::Agents { room } => {
let room = room.map(RoomId::parse).transpose().context("room id")?;
let agents = client
.list_agents(room)
.await
.map_err(|e| anyhow::anyhow!("list agents: {e}"))?;
if json {
let rows: Vec<_> = agents
.iter()
.map(|a| {
json!({
"agent_id": a.agent_id.as_str(),
"name": a.card.name,
"version": a.card.version,
})
})
.collect();
writeln!(out, "{}", json!({ "total": rows.len(), "agents": rows }))?;
} else if agents.is_empty() {
writeln!(out, "no agents")?;
} else {
for a in &agents {
writeln!(
out,
"{}\t{}\t{}",
a.agent_id.as_str(),
a.card.name,
a.card.version
)?;
}
}
}
CommsAgentCmd::RoomCreate { room, scope, title } => {
let room_id = RoomId::parse(room).context("room id")?;
let scope = parse_scope(&scope)?;
let created = client
.create_room(room_id, scope, title)
.await
.map_err(|e| anyhow::anyhow!("create room: {e}"))?;
render_room(&created, json, out)?;
}
CommsAgentCmd::Rooms => {
let (remote, cwd) = scope_context_for(root);
let rooms = client
.list_rooms(remote, cwd)
.await
.map_err(|e| anyhow::anyhow!("list rooms: {e}"))?;
if json {
let rows: Vec<_> = rooms.iter().map(room_json).collect();
writeln!(out, "{}", json!({ "total": rows.len(), "rooms": rows }))?;
} else if rooms.is_empty() {
writeln!(out, "no rooms")?;
} else {
for r in &rooms {
writeln!(out, "{}\t{}", r.room_id.as_str(), r.title)?;
}
}
}
CommsAgentCmd::Join { room } => {
let room_id = RoomId::parse(room).context("room id")?;
let label = room_id.as_str().to_string();
client
.join_room(room_id)
.await
.map_err(|e| anyhow::anyhow!("join: {e}"))?;
if json {
writeln!(out, "{}", json!({ "room": label, "joined": true }))?;
} else {
writeln!(out, "joined {label}")?;
}
}
CommsAgentCmd::Leave { room } => {
let room_id = RoomId::parse(room).context("room id")?;
let label = room_id.as_str().to_string();
client
.leave_room(room_id)
.await
.map_err(|e| anyhow::anyhow!("leave: {e}"))?;
if json {
writeln!(out, "{}", json!({ "room": label, "left": true }))?;
} else {
writeln!(out, "left {label}")?;
}
}
CommsAgentCmd::Post {
room,
subject,
body,
tags,
reply_to,
} => {
let room_id = RoomId::parse(room).context("room id")?;
let body = body.unwrap_or_default().into_bytes();
let message_id = client
.post_message(room_id, subject, body, tags, reply_to, Vec::new())
.await
.map_err(|e| anyhow::anyhow!("post: {e}"))?;
if json {
writeln!(out, "{}", json!({ "message_id": message_id }))?;
} else {
writeln!(out, "{message_id}")?;
}
}
CommsAgentCmd::History {
room,
cursor,
limit,
} => {
let room_id = RoomId::parse(room).context("room id")?;
let (messages, next_cursor) = client
.read_history(room_id, cursor.map(Cursor), clamp_limit(limit))
.await
.map_err(|e| anyhow::anyhow!("history: {e}"))?;
render_front_matter(&messages, next_cursor.as_ref(), None, json, out)?;
}
CommsAgentCmd::Read { message_id } => {
let id = message_id.clone();
let body = client
.get_body(message_id)
.await
.map_err(|e| anyhow::anyhow!("read: {e}"))?;
let text = body.map(|b| String::from_utf8_lossy(&b).into_owned());
if json {
writeln!(
out,
"{}",
json!({ "message_id": id, "found": text.is_some(), "body": text })
)?;
} else {
match text {
Some(b) => writeln!(out, "{b}")?,
None => writeln!(out, "(no such message)")?,
}
}
}
CommsAgentCmd::Inbox {
cursor,
limit,
mark_read,
} => {
let (remote, cwd) = scope_context_for(root);
let (messages, unread, next_cursor) = client
.read_inbox(
remote,
cwd,
cursor.map(Cursor),
clamp_limit(limit),
mark_read,
)
.await
.map_err(|e| anyhow::anyhow!("inbox: {e}"))?;
render_front_matter(&messages, next_cursor.as_ref(), Some(unread), json, out)?;
}
}
Ok(())
}
fn room_json(room: &Room) -> serde_json::Value {
json!({
"room_id": room.room_id.as_str(),
"title": room.title,
"created_at": room.created_at,
})
}
fn render_room(room: &Room, json: bool, out: &mut impl Write) -> Result<()> {
if json {
writeln!(out, "{}", json!({ "room": room_json(room) }))?;
} else {
writeln!(out, "{}\t{}", room.room_id.as_str(), room.title)?;
}
Ok(())
}
fn render_front_matter(
messages: &[SeqMeta],
next_cursor: Option<&Cursor>,
unread: Option<u32>,
json: bool,
out: &mut impl Write,
) -> Result<()> {
if json {
let rows: Vec<_> = messages
.iter()
.map(|sm| {
let m = &sm.meta;
json!({
"id": m.id,
"room": m.room.as_str(),
"from": m.from.as_str(),
"ts_micros": m.ts_micros,
"subject": m.subject,
"tags": m.tags,
"scope": m.scope,
"reply_to": m.reply_to,
"seq": sm.seq,
"body_len": m.body_len,
})
})
.collect();
let mut obj = json!({ "total": rows.len(), "messages": rows });
if let Some(u) = unread {
obj["unread"] = json!(u);
}
if let Some(c) = next_cursor {
obj["next_cursor"] = json!(c.0);
}
writeln!(out, "{obj}")?;
return Ok(());
}
if let Some(u) = unread {
writeln!(out, "unread: {u}")?;
}
if messages.is_empty() {
writeln!(out, "(no messages)")?;
} else {
for sm in messages {
let m = &sm.meta;
writeln!(
out,
"{}\t{}\t{}\t{}",
m.subject,
m.from.as_str(),
m.ts_micros,
m.id
)?;
}
}
if let Some(c) = next_cursor {
writeln!(out, "next_cursor: {}", c.0)?;
}
Ok(())
}