use crate::cli::{
grpc_connect, print_node_table, NodeCommands, NodeCreateArgs, NodeDeleteArgs, NodeGetArgs,
NodeListArgs, NodeStatsArgs,
};
use anyhow::Result;
use cortex_proto::*;
use prost_types;
pub async fn run(cmd: NodeCommands, server: &str) -> Result<()> {
match cmd {
NodeCommands::Create(args) => create(args, server).await,
NodeCommands::Get(args) => get(args, server).await,
NodeCommands::List(args) => list(args, server).await,
NodeCommands::Delete(args) => delete(args, server).await,
NodeCommands::Stats(args) => stats(args, server).await,
}
}
async fn create(args: NodeCreateArgs, server: &str) -> Result<()> {
let mut client = grpc_connect(server).await?;
let body = if args.stdin {
use std::io::Read;
let mut s = String::new();
std::io::stdin().read_to_string(&mut s)?;
s.trim().to_string()
} else {
args.body.unwrap_or_else(|| args.title.clone())
};
let metadata: std::collections::HashMap<String, String> = match &args.metadata {
Some(json_str) => {
let val: serde_json::Value = serde_json::from_str(json_str)
.map_err(|e| anyhow::anyhow!("Invalid --metadata JSON: {}", e))?;
match val {
serde_json::Value::Object(map) => map
.into_iter()
.map(|(k, v)| {
let s = match v {
serde_json::Value::String(s) => s,
other => other.to_string(),
};
(k, s)
})
.collect(),
_ => return Err(anyhow::anyhow!("--metadata must be a JSON object")),
}
}
None => std::collections::HashMap::new(),
};
if args.check_conventions {
use cortex_core::conventions;
use cortex_core::types::{Node, NodeKind, Source};
let kind = NodeKind::new(&args.kind).unwrap_or_else(|_| NodeKind::new("fact").unwrap());
let mut node = Node::new(
kind,
args.title.clone(),
body.clone(),
Source {
agent: "cli".into(),
session: None,
channel: None,
},
args.importance,
);
for (k, v) in &metadata {
let json_val =
serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.clone()));
node.data.metadata.insert(k.clone(), json_val);
}
let warnings = conventions::check_conventions(&node);
for w in &warnings {
eprintln!("Warning: {}", w);
}
}
let mut req = CreateNodeRequest {
kind: args.kind,
title: args.title,
body,
importance: args.importance,
tags: args.tags,
source_agent: "cli".into(),
valid_from: parse_optional_timestamp(&args.valid_from)?,
valid_until: parse_optional_timestamp(&args.valid_until)?,
expires_at: parse_optional_timestamp(&args.expires_at)?,
..Default::default()
};
req.metadata = metadata;
let resp = client.create_node(req).await?.into_inner();
if args.format == "json" {
println!(
"{}",
serde_json::json!({
"id": resp.id,
"kind": resp.kind,
"title": resp.title,
"importance": resp.importance,
})
);
} else {
println!("Created node {}", resp.id);
print_node_detail(&resp);
}
Ok(())
}
async fn get(args: NodeGetArgs, server: &str) -> Result<()> {
let mut client = grpc_connect(server).await?;
let resp = client
.get_node(GetNodeRequest { id: args.id })
.await?
.into_inner();
if args.format == "json" {
println!(
"{}",
serde_json::json!({
"id": resp.id,
"kind": resp.kind,
"title": resp.title,
"body": resp.body,
"importance": resp.importance,
"tags": resp.tags,
"source_agent": resp.source_agent,
"access_count": resp.access_count,
"has_embedding": resp.has_embedding,
})
);
} else {
print_node_detail(&resp);
}
Ok(())
}
async fn list(args: NodeListArgs, server: &str) -> Result<()> {
let mut client = grpc_connect(server).await?;
let kind_filter = args.kind.map(|k| vec![k]).unwrap_or_default();
let source_agent = args.source.unwrap_or_default();
let resp = client
.list_nodes(ListNodesRequest {
kind_filter,
source_agent,
limit: args.limit,
..Default::default()
})
.await?
.into_inner();
if args.format == "json" {
let nodes: Vec<_> = resp
.nodes
.iter()
.map(|n| {
serde_json::json!({
"id": n.id,
"kind": n.kind,
"title": n.title,
"importance": n.importance,
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&nodes)?);
} else {
println!("Total: {} nodes", resp.total_count);
print_node_table(&resp.nodes);
}
Ok(())
}
async fn delete(args: NodeDeleteArgs, server: &str) -> Result<()> {
if !args.yes {
use inquire::Confirm;
let confirmed = Confirm::new(&format!("Delete node {}?", args.id))
.with_default(false)
.prompt()?;
if !confirmed {
println!("Aborted.");
return Ok(());
}
}
let mut client = grpc_connect(server).await?;
let resp = client
.delete_node(DeleteNodeRequest {
id: args.id.clone(),
})
.await?
.into_inner();
if resp.success {
println!("Deleted node {}", args.id);
} else {
println!("Node {} not found", args.id);
}
Ok(())
}
async fn stats(args: NodeStatsArgs, server: &str) -> Result<()> {
use cortex_proto::GetNodeRequest;
let mut client = grpc_connect(server).await?;
let n = client
.get_node(GetNodeRequest { id: args.id })
.await?
.into_inner();
if args.format == "json" {
println!(
"{}",
serde_json::json!({
"id": n.id,
"kind": n.kind,
"title": n.title,
"access_count": n.access_count,
"last_accessed_at": fmt_timestamp(n.last_accessed_at.as_ref()),
"created_at": fmt_timestamp(n.created_at.as_ref()),
"updated_at": fmt_timestamp(n.updated_at.as_ref()),
"days_since_access": days_since(n.last_accessed_at.as_ref()),
})
);
} else {
println!();
println!("Node Access Stats");
println!("{}", "─".repeat(50));
println!("ID: {}", n.id);
println!("Kind: {}", n.kind);
println!("Title: {}", crate::cli::truncate(&n.title, 40));
println!("{}", "─".repeat(50));
println!("Access count: {}", n.access_count);
println!(
"Last accessed: {}",
fmt_timestamp(n.last_accessed_at.as_ref())
);
if let Some(days) = days_since(n.last_accessed_at.as_ref()) {
println!("Days idle: {:.1}", days);
}
println!("Created: {}", fmt_timestamp(n.created_at.as_ref()));
println!("Updated: {}", fmt_timestamp(n.updated_at.as_ref()));
println!("{}", "─".repeat(50));
println!();
}
Ok(())
}
fn parse_optional_timestamp(s: &Option<String>) -> Result<Option<prost_types::Timestamp>> {
match s {
None => Ok(None),
Some(s) => {
let dt = chrono::DateTime::parse_from_rfc3339(s)
.or_else(|_| chrono::DateTime::parse_from_rfc3339(&format!("{s}T00:00:00Z")))
.map_err(|e| anyhow::anyhow!("Invalid timestamp '{}': {}", s, e))?;
Ok(Some(prost_types::Timestamp {
seconds: dt.timestamp(),
nanos: dt.timestamp_subsec_nanos() as i32,
}))
}
}
}
fn fmt_timestamp(ts: Option<&prost_types::Timestamp>) -> String {
match ts {
None => "—".to_string(),
Some(t) => match chrono::DateTime::from_timestamp(t.seconds, t.nanos as u32) {
Some(dt) => dt.format("%Y-%m-%d %H:%M UTC").to_string(),
None => "invalid".to_string(),
},
}
}
fn days_since(ts: Option<&prost_types::Timestamp>) -> Option<f64> {
let t = ts?;
let dt = chrono::DateTime::from_timestamp(t.seconds, t.nanos as u32)?;
let elapsed = chrono::Utc::now().signed_duration_since(dt);
Some(elapsed.num_seconds().max(0) as f64 / 86_400.0)
}
pub fn print_node_detail(n: &NodeResponse) {
println!("ID: {}", n.id);
println!("Kind: {}", n.kind);
println!("Title: {}", n.title);
println!("Body: {}", crate::cli::truncate(&n.body, 120));
println!("Importance: {:.2}", n.importance);
println!("Tags: {}", n.tags.join(", "));
println!("Source: {}", n.source_agent);
println!("Access: {}", n.access_count);
println!("Last seen: {}", fmt_timestamp(n.last_accessed_at.as_ref()));
println!("Embedding: {}", if n.has_embedding { "yes" } else { "no" });
if n.valid_from.is_some() {
println!("Valid from: {}", fmt_timestamp(n.valid_from.as_ref()));
}
if n.valid_until.is_some() {
println!("Valid until:{}", fmt_timestamp(n.valid_until.as_ref()));
}
if n.expires_at.is_some() {
println!("Expires at: {}", fmt_timestamp(n.expires_at.as_ref()));
}
if let Some(model) = &n.embedding_model {
println!("Emb model: {}", model);
}
}