Skip to main content

ai_memory/cli/commands/
inbox.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 inbox` CLI subcommand.
5//!
6//! Closes the three-surface-parity gap on `memory_inbox`. The MCP
7//! tool ([`crate::mcp::handle_inbox`]) and the HTTP route landed
8//! previously; this module wires the CLI surface so operators can
9//! read an agent inbox (`_messages/<agent_id>/`) from a terminal.
10
11use anyhow::Result;
12use clap::Args;
13use serde_json::{Value, json};
14
15use crate::cli::CliOutput;
16use crate::storage as db;
17
18/// CLI args for `ai-memory inbox`.
19#[derive(Args, Debug, Clone)]
20pub struct InboxArgs {
21    /// Inbox owner. Default = caller agent_id.
22    #[arg(long = "agent-id", value_name = "AGENT_ID")]
23    pub agent_id: Option<String>,
24
25    /// Only return messages with `access_count == 0`.
26    #[arg(long = "unread-only")]
27    pub unread_only: bool,
28
29    /// Default 50, cap 500.
30    #[arg(long, value_name = "N")]
31    pub limit: Option<u32>,
32
33    /// Emit the raw JSON envelope.
34    #[arg(long)]
35    pub json: bool,
36}
37
38/// `ai-memory inbox` dispatch entry.
39///
40/// # Errors
41///
42/// - The DB at `db_path` cannot be opened.
43/// - The substrate refuses the listing.
44/// - `serde_json::to_string` cannot serialise the envelope.
45pub fn cmd_inbox(
46    db_path: &std::path::Path,
47    args: &InboxArgs,
48    out: &mut CliOutput<'_>,
49) -> Result<()> {
50    let conn = db::open(db_path)?;
51
52    let mut params = json!({});
53    if let Some(a) = &args.agent_id {
54        params["agent_id"] = json!(a);
55    }
56    if args.unread_only {
57        params[crate::models::field_names::UNREAD_ONLY] = json!(true);
58    }
59    if let Some(l) = args.limit {
60        params["limit"] = json!(l);
61    }
62
63    // CLI is single-tenant (the operator runs it locally) → trust-all caller
64    // (None), preserving the existing `--agent-id`-selects-inbox behavior. #1557.
65    let envelope = crate::mcp::handle_inbox(&conn, &params, None, None)
66        .map_err(|e| anyhow::anyhow!("inbox: {e}"))?;
67
68    if args.json {
69        writeln!(out.stdout, "{}", serde_json::to_string(&envelope)?)?;
70        return Ok(());
71    }
72
73    let count = envelope.get("count").and_then(Value::as_u64).unwrap_or(0);
74    let owner = envelope
75        .get("agent_id")
76        .and_then(Value::as_str)
77        .unwrap_or("?");
78    writeln!(out.stdout, "inbox: {count} message(s) for {owner}")?;
79    if let Some(arr) = envelope.get("messages").and_then(Value::as_array) {
80        for m in arr {
81            let id = m.get("id").and_then(Value::as_str).unwrap_or("?");
82            let from = m.get("from").and_then(Value::as_str).unwrap_or("?");
83            let title = m.get("title").and_then(Value::as_str).unwrap_or("");
84            let read = m.get("read").and_then(Value::as_bool).unwrap_or(false);
85            writeln!(out.stdout, "  {id}  from={from}  read={read}  {title}")?;
86        }
87    }
88    Ok(())
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use crate::cli::test_utils::TestEnv;
95
96    #[test]
97    fn inbox_cli_empty_db_returns_zero() {
98        let mut env = TestEnv::fresh();
99        let db = env.db_path.clone();
100        let args = InboxArgs {
101            agent_id: Some("ai:alice".into()),
102            unread_only: false,
103            limit: None,
104            json: true,
105        };
106        {
107            let mut out = env.output();
108            cmd_inbox(&db, &args, &mut out).expect("ok");
109        }
110        let stdout = env.stdout_str();
111        let envelope: Value = serde_json::from_str(stdout.trim()).expect("parse envelope");
112        assert_eq!(envelope["count"].as_u64(), Some(0));
113    }
114
115    #[test]
116    fn inbox_cli_text_output_lists_messages() {
117        let mut env = TestEnv::fresh();
118        let db = env.db_path.clone();
119        // Seed a message into _messages/ai:bob (the inbox namespace).
120        crate::cli::test_utils::seed_memory(
121            &db,
122            "_messages/ai:bob",
123            "hello bob",
124            "message payload",
125        );
126        let args = InboxArgs {
127            agent_id: Some("ai:bob".into()),
128            unread_only: false,
129            limit: Some(10),
130            json: false,
131        };
132        {
133            let mut out = env.output();
134            cmd_inbox(&db, &args, &mut out).expect("ok");
135        }
136        let stdout = env.stdout_str();
137        assert!(stdout.contains("1 message(s) for ai:bob"), "got: {stdout}");
138        assert!(stdout.contains("from=test-agent"), "got: {stdout}");
139        assert!(stdout.contains("hello bob"), "got: {stdout}");
140        assert!(stdout.contains("read=false"), "got: {stdout}");
141    }
142
143    #[test]
144    fn inbox_cli_unread_only_filters() {
145        let mut env = TestEnv::fresh();
146        let db = env.db_path.clone();
147        crate::cli::test_utils::seed_memory(&db, "_messages/ai:carol", "msg", "body");
148        let args = InboxArgs {
149            agent_id: Some("ai:carol".into()),
150            unread_only: true,
151            limit: None,
152            json: true,
153        };
154        {
155            let mut out = env.output();
156            cmd_inbox(&db, &args, &mut out).expect("ok");
157        }
158        let envelope: Value = serde_json::from_str(env.stdout_str().trim()).expect("json");
159        // Freshly seeded row has access_count==0 → unread → still listed.
160        assert_eq!(envelope["count"].as_u64(), Some(1));
161    }
162}