Skip to main content

ai_memory/cli/commands/
notify.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! v0.7.0 ARCH-3 / FX-C3 (batch2) — `ai-memory notify` CLI subcommand.
5//!
6//! Closes the three-surface-parity gap on `memory_notify`. The MCP
7//! tool ([`crate::mcp::handle_notify`]) and the HTTP route landed
8//! previously; this module wires the CLI surface so operators can
9//! send an inter-agent inbox message from a terminal.
10//!
11//! ## DRY contract
12//!
13//! No business logic lives here — validation, namespace resolution
14//! (`_messages/<target>/`), and the per-tier expiry computation live
15//! in [`crate::mcp::handle_notify`]. The MCP, HTTP, and CLI surfaces
16//! share that one implementation.
17
18use crate::models::field_names;
19use anyhow::Result;
20use clap::Args;
21use serde_json::{Value, json};
22
23use crate::cli::CliOutput;
24use crate::config::AppConfig;
25use crate::storage as db;
26
27/// CLI args for `ai-memory notify`.
28#[derive(Args, Debug, Clone)]
29pub struct NotifyArgs {
30    /// Recipient agent_id.
31    #[arg(long = "target-agent-id", value_name = "AGENT_ID")]
32    pub target_agent_id: String,
33
34    /// Subject (<= 200 chars).
35    #[arg(long, value_name = "TEXT")]
36    pub title: String,
37
38    /// Message body.
39    #[arg(long, value_name = "TEXT")]
40    pub payload: String,
41
42    /// Default 5; clamped 1..=10.
43    #[arg(long, value_name = "N")]
44    pub priority: Option<i64>,
45
46    /// Tier: short=6h, mid=7d (default), long=no expiry.
47    #[arg(long, value_name = "TIER")]
48    pub tier: Option<String>,
49
50    /// Emit the raw JSON envelope.
51    #[arg(long)]
52    pub json: bool,
53}
54
55/// `ai-memory notify` dispatch entry.
56///
57/// # Errors
58///
59/// - The DB at `db_path` cannot be opened.
60/// - The substrate refuses the notify (validation, tier parse, etc.).
61/// - `serde_json::to_string` cannot serialise the envelope.
62pub fn cmd_notify(
63    db_path: &std::path::Path,
64    args: &NotifyArgs,
65    app_config: &AppConfig,
66    out: &mut CliOutput<'_>,
67) -> Result<()> {
68    let conn = db::open(db_path)?;
69    let resolved_ttl = app_config.effective_ttl();
70
71    let mut params = json!({
72        (field_names::TARGET_AGENT_ID): args.target_agent_id,
73        "title": args.title,
74        "payload": args.payload,
75    });
76    if let Some(p) = args.priority {
77        params["priority"] = json!(p);
78    }
79    if let Some(t) = &args.tier {
80        params["tier"] = json!(t);
81    }
82
83    let envelope = crate::mcp::handle_notify(&conn, &params, &resolved_ttl, None)
84        .map_err(|e| anyhow::anyhow!("notify: {e}"))?;
85
86    if args.json {
87        writeln!(out.stdout, "{}", serde_json::to_string(&envelope)?)?;
88        return Ok(());
89    }
90
91    let id = envelope.get("id").and_then(Value::as_str).unwrap_or("?");
92    let to = envelope.get("to").and_then(Value::as_str).unwrap_or("?");
93    writeln!(out.stdout, "notify: id={id}  to={to}")?;
94    Ok(())
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use crate::cli::test_utils::TestEnv;
101
102    #[test]
103    fn notify_cli_invalid_target_returns_err() {
104        let mut env = TestEnv::fresh();
105        let db = env.db_path.clone();
106        let cfg = AppConfig::default();
107        let args = NotifyArgs {
108            target_agent_id: "bad agent with spaces".into(),
109            title: "subject".into(),
110            payload: "body".into(),
111            priority: None,
112            tier: None,
113            json: true,
114        };
115        let mut out = env.output();
116        let err = cmd_notify(&db, &args, &cfg, &mut out).expect_err("must fail");
117        assert!(err.to_string().contains("notify"), "got: {err}");
118    }
119
120    #[test]
121    fn notify_cli_happy_path_writes_envelope() {
122        let mut env = TestEnv::fresh();
123        let db = env.db_path.clone();
124        let cfg = AppConfig::default();
125        let args = NotifyArgs {
126            target_agent_id: "ai:bob".into(),
127            title: "subject".into(),
128            payload: "body".into(),
129            priority: Some(7),
130            tier: Some("mid".into()),
131            json: true,
132        };
133        {
134            let mut out = env.output();
135            cmd_notify(&db, &args, &cfg, &mut out).expect("notify ok");
136        }
137        let stdout = env.stdout_str();
138        let envelope: Value = serde_json::from_str(stdout.trim()).expect("parse envelope");
139        assert_eq!(envelope["to"].as_str(), Some("ai:bob"));
140    }
141}