Skip to main content

ai_memory/cli/commands/
subscribe.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 subscribe` CLI subcommand.
5//!
6//! Closes the three-surface-parity gap on `memory_subscribe`. The MCP
7//! tool ([`crate::mcp::handle_subscribe`]) and the HTTP route landed
8//! previously; this module wires the CLI surface so operators can
9//! register a webhook subscription from a terminal.
10//!
11//! ## DRY contract
12//!
13//! No business logic lives here — the URL validation, HMAC secret
14//! requirement (R3-S1.HMAC v0.7.0), and registered-agent gate live in
15//! [`crate::mcp::handle_subscribe`]. 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::storage as db;
25
26/// CLI args for `ai-memory subscribe`. Mirrors the MCP
27/// `memory_subscribe` `input_schema` shape.
28#[derive(Args, Debug, Clone)]
29pub struct SubscribeArgs {
30    /// Webhook URL the daemon will POST events to.
31    #[arg(long, value_name = "URL")]
32    pub url: String,
33
34    /// Comma-separated event-name filter, or "*" for all. Default "*".
35    #[arg(long, value_name = "CSV")]
36    pub events: Option<String>,
37
38    /// HMAC secret. Required when no server-wide
39    /// `[hooks.subscription] hmac_secret` is configured.
40    #[arg(long, value_name = "SECRET")]
41    pub secret: Option<String>,
42
43    /// Optional namespace filter.
44    #[arg(long = "namespace-filter", value_name = "NS")]
45    pub namespace_filter: Option<String>,
46
47    /// Optional agent_id filter.
48    #[arg(long = "agent-filter", value_name = "AGENT_ID")]
49    pub agent_filter: Option<String>,
50
51    /// Optional structured per-event-type opt-in (comma-separated).
52    #[arg(long = "event-types", value_name = "CSV", value_delimiter = ',')]
53    pub event_types: Vec<String>,
54
55    /// Emit the raw JSON envelope (the same shape MCP / HTTP return).
56    #[arg(long)]
57    pub json: bool,
58}
59
60/// `ai-memory subscribe` dispatch entry.
61///
62/// # Errors
63///
64/// - The DB at `db_path` cannot be opened.
65/// - The substrate refuses the registration (missing HMAC secret,
66///   unregistered agent, malformed URL, etc.).
67/// - `serde_json::to_string` cannot serialise the envelope.
68pub fn cmd_subscribe(
69    db_path: &std::path::Path,
70    args: &SubscribeArgs,
71    out: &mut CliOutput<'_>,
72) -> Result<()> {
73    let conn = db::open(db_path)?;
74
75    let mut params = json!({"url": args.url});
76    if let Some(e) = &args.events {
77        params["events"] = json!(e);
78    }
79    if let Some(s) = &args.secret {
80        params["secret"] = json!(s);
81    }
82    if let Some(ns) = &args.namespace_filter {
83        params[field_names::NAMESPACE_FILTER] = json!(ns);
84    }
85    if let Some(a) = &args.agent_filter {
86        params[field_names::AGENT_FILTER] = json!(a);
87    }
88    if !args.event_types.is_empty() {
89        params[field_names::EVENT_TYPES] = json!(args.event_types);
90    }
91
92    let envelope = crate::mcp::handle_subscribe(&conn, &params, None)
93        .map_err(|e| anyhow::anyhow!("subscribe: {e}"))?;
94
95    if args.json {
96        writeln!(out.stdout, "{}", serde_json::to_string(&envelope)?)?;
97        return Ok(());
98    }
99
100    let id = envelope.get("id").and_then(Value::as_str).unwrap_or("?");
101    let url = envelope.get("url").and_then(Value::as_str).unwrap_or("?");
102    writeln!(out.stdout, "subscribe: id={id}  url={url}")?;
103    Ok(())
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::cli::test_utils::TestEnv;
110
111    #[test]
112    fn subscribe_cli_unregistered_agent_returns_err() {
113        let mut env = TestEnv::fresh();
114        let db = env.db_path.clone();
115        let args = SubscribeArgs {
116            url: "https://example.com/hook".into(),
117            events: None,
118            secret: Some("topsecret".into()),
119            namespace_filter: None,
120            agent_filter: None,
121            event_types: vec![],
122            json: true,
123        };
124        let mut out = env.output();
125        // The CLI dispatcher caller is not registered in `_agents` →
126        // substrate refuses with the registration-required error.
127        let err = cmd_subscribe(&db, &args, &mut out).expect_err("must fail");
128        assert!(
129            err.to_string().contains("subscribe") || err.to_string().contains("register"),
130            "got: {err}"
131        );
132    }
133
134    /// Register the CLI-resolved agent so the success path (envelope
135    /// shaping) is exercised. Drives both json + text output and every
136    /// optional param arm.
137    #[test]
138    fn subscribe_cli_success_json_with_all_params() {
139        crate::config::set_active_hooks_hmac_secret(None);
140        let mut env = TestEnv::fresh();
141        let db = env.db_path.clone();
142        {
143            let conn = db::open(&db).unwrap();
144            let agent_id = crate::identity::resolve_agent_id(None, None).unwrap();
145            db::register_agent(&conn, &agent_id, "test", &[]).expect("register");
146        }
147        let args = SubscribeArgs {
148            url: "https://example.com/hook".into(),
149            events: Some("memory_store,memory_link_created".into()),
150            secret: Some("topsecret".into()),
151            namespace_filter: Some("ns".into()),
152            agent_filter: Some("ai:other".into()),
153            event_types: vec!["memory_store".into()],
154            json: true,
155        };
156        {
157            let mut out = env.output();
158            cmd_subscribe(&db, &args, &mut out).expect("subscribe ok");
159        }
160        let envelope: Value = serde_json::from_str(env.stdout_str().trim()).expect("json");
161        assert!(envelope["id"].is_string());
162        assert_eq!(envelope["url"], "https://example.com/hook");
163    }
164
165    #[test]
166    fn subscribe_cli_success_text_output() {
167        crate::config::set_active_hooks_hmac_secret(None);
168        let mut env = TestEnv::fresh();
169        let db = env.db_path.clone();
170        {
171            let conn = db::open(&db).unwrap();
172            let agent_id = crate::identity::resolve_agent_id(None, None).unwrap();
173            db::register_agent(&conn, &agent_id, "test", &[]).expect("register");
174        }
175        let args = SubscribeArgs {
176            url: "https://example.com/hook2".into(),
177            events: None,
178            secret: Some("topsecret".into()),
179            namespace_filter: None,
180            agent_filter: None,
181            event_types: vec![],
182            json: false,
183        };
184        {
185            let mut out = env.output();
186            cmd_subscribe(&db, &args, &mut out).expect("subscribe ok");
187        }
188        let stdout = env.stdout_str();
189        assert!(stdout.contains("subscribe: id="), "got: {stdout}");
190        assert!(
191            stdout.contains("url=https://example.com/hook2"),
192            "got: {stdout}"
193        );
194    }
195}