ai_memory/cli/share.rs
1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! v0.7.0 #1095 — `ai-memory share` CLI subcommand.
5//!
6//! Closes the SR-4 three-surface-parity gap on `memory_share`. The MCP
7//! tool ([`crate::mcp::tools::share::handle_share`]) and the HTTP route
8//! (`POST /api/v1/share`, [`crate::handlers::share::share_memory`])
9//! landed at v0.7.0 RC; this module wires the third surface so operators
10//! can share a memory from a terminal without driving MCP-stdio JSON-RPC
11//! or constructing an HTTP request by hand.
12//!
13//! ## Wire shape
14//!
15//! ```text
16//! ai-memory share \
17//! --memory-id <source uuid or unique prefix> \
18//! --target-agent <recipient agent_id> \
19//! [--json]
20//! ```
21//!
22//! Both `--memory-id` and `--target-agent` are required and validated by
23//! the shared substrate primitive ([`crate::validate::validate_id`] /
24//! [`crate::validate::validate_agent_id`]). The dispatch is byte-equal
25//! to the MCP tool so the JSON envelope (`shared_memory_id`,
26//! `source_memory_id`, `target_namespace`, `target_agent_id`,
27//! `from_agent_id`) round-trips intact.
28//!
29//! ## DRY contract
30//!
31//! No business logic lives here — this module is a clap arg-parser plus
32//! an output formatter. The actual share semantics (provenance metadata,
33//! `_shared/<from>→<to>/` namespace construction, fresh row insert) live
34//! in [`crate::mcp::tools::share::handle_share`]. The MCP, HTTP, and CLI
35//! surfaces share that one implementation; adding a CLI verb is one
36//! `Command::Share(ShareArgs)` arm + this module.
37
38use crate::models::field_names;
39use anyhow::Result;
40use clap::Args;
41use serde_json::{Value, json};
42
43use crate::cli::CliOutput;
44use crate::storage as db;
45
46/// CLI args for `ai-memory share`. Mirrors the MCP `memory_share`
47/// `input_schema` shape: `source_memory_id` (here exposed as
48/// `--memory-id` for shell ergonomics) + `target_agent_id` (here
49/// `--target-agent` for the same reason). The substrate primitive
50/// accepts both spellings via the JSON params bag the CLI constructs
51/// below.
52#[derive(Args, Debug, Clone)]
53pub struct ShareArgs {
54 /// Memory id (full UUID or unique prefix) to share. Resolved by
55 /// the same `validate_id` + `resolve_id` substrate path the MCP
56 /// tool uses, so callers can pass the short-id shell flow.
57 #[arg(long = "memory-id", value_name = "ID")]
58 pub memory_id: String,
59
60 /// Recipient agent id. Must satisfy `validate_agent_id` (the same
61 /// validator the MCP tool routes through). Typical: `ai:bob`,
62 /// `host:node-2`, or any `[A-Za-z0-9_\\-:@./]{1,128}` token.
63 #[arg(long = "target-agent", value_name = "AGENT_ID")]
64 pub target_agent: String,
65
66 /// Emit the raw JSON envelope (the same shape MCP / HTTP return)
67 /// instead of the human-readable summary line.
68 #[arg(long)]
69 pub json: bool,
70}
71
72/// `ai-memory share` dispatch entry. Opens the DB at `db_path`, builds
73/// the same JSON params bag the MCP tool consumes, and routes through
74/// the shared [`crate::mcp::tools::share::handle_share`] substrate
75/// primitive — guaranteeing the wire envelope is byte-equal across the
76/// three surfaces.
77///
78/// # Errors
79///
80/// - The DB at `db_path` cannot be opened.
81/// - The substrate validation rejects the supplied `--memory-id` /
82/// `--target-agent` (invalid format, source row not found, …).
83/// - `serde_json::to_string` cannot serialise the envelope (in practice
84/// never happens with the shapes used here).
85pub fn cmd_share(
86 db_path: &std::path::Path,
87 args: &ShareArgs,
88 out: &mut CliOutput<'_>,
89) -> Result<()> {
90 let conn = db::open(db_path)?;
91
92 // Build the JSON params bag the substrate primitive consumes. The
93 // CLI flag names diverge from the MCP wire shape for shell
94 // ergonomics (`--memory-id` vs `source_memory_id`) but the shared
95 // dispatcher only sees the canonical MCP field names.
96 let params: Value = json!({
97 (field_names::SOURCE_MEMORY_ID): args.memory_id,
98 (field_names::TARGET_AGENT_ID): args.target_agent,
99 });
100
101 let envelope = crate::mcp::share::handle_share(&conn, ¶ms)
102 .map_err(|e| anyhow::anyhow!("share: {e}"))?;
103
104 if args.json {
105 writeln!(out.stdout, "{}", serde_json::to_string(&envelope)?)?;
106 return Ok(());
107 }
108
109 // Human-readable summary. Pull the four documented envelope keys
110 // and emit a single line. Defensive `unwrap_or("?")` on the off-
111 // chance the substrate primitive ever drops a field — the wire
112 // contract is pinned by the MCP + HTTP integration tests.
113 let shared_id = envelope
114 .get("shared_memory_id")
115 .and_then(Value::as_str)
116 .unwrap_or("?");
117 let target_ns = envelope
118 .get(field_names::TARGET_NAMESPACE)
119 .and_then(Value::as_str)
120 .unwrap_or("?");
121 let from_agent = envelope
122 .get(field_names::FROM_AGENT_ID)
123 .and_then(Value::as_str)
124 .unwrap_or("?");
125 writeln!(
126 out.stdout,
127 "shared {} ({} → {}) into {}",
128 shared_id, from_agent, args.target_agent, target_ns,
129 )?;
130 Ok(())
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136 use crate::cli::test_utils::{TestEnv, seed_memory};
137
138 /// v0.7.0 #1095 — CLI share happy path. Pins the third surface of
139 /// the three-surface-parity contract: a row stored under one agent
140 /// is copied into the `_shared/<from>→<to>/` namespace when shared.
141 /// MCP + HTTP pin the same invariant; this test ensures the CLI
142 /// arm reaches the substrate primitive without dropping fields.
143 #[test]
144 fn share_cli_copies_memory_into_shared_namespace_1095() {
145 let mut env = TestEnv::fresh();
146 let db = env.db_path.clone();
147 // Seed a source memory authored by ai:alice (the seed_memory
148 // helper stamps `agent_id` into the metadata via the default
149 // construction path).
150 let src_id = seed_memory(&db, "alice/notes", "shared-src-cli", "share me via CLI");
151 // The seed helper does NOT stamp a specific agent_id; pre-seed
152 // a metadata.agent_id so the share primitive can derive the
153 // `from_agent_id` envelope field. Open a connection and patch
154 // the row.
155 {
156 let conn = db::open(&db).expect("open db for metadata patch");
157 conn.execute(
158 "UPDATE memories SET metadata = json_set(metadata, '$.agent_id', 'ai:alice') WHERE id = ?1",
159 rusqlite::params![src_id],
160 )
161 .expect("patch metadata.agent_id");
162 }
163
164 let args = ShareArgs {
165 memory_id: src_id.clone(),
166 target_agent: "ai:bob".to_string(),
167 json: true,
168 };
169 {
170 let mut out = env.output();
171 cmd_share(&db, &args, &mut out).expect("share ok");
172 }
173 let stdout = env.stdout_str();
174 let envelope: Value = serde_json::from_str(stdout.trim()).expect("parse envelope");
175 // Wire shape parity with MCP + HTTP.
176 assert!(
177 envelope["shared_memory_id"].is_string(),
178 "#1095: shared_memory_id present"
179 );
180 assert_eq!(
181 envelope["source_memory_id"], src_id,
182 "#1095: source_memory_id echoes input"
183 );
184 assert_eq!(
185 envelope["target_agent_id"], "ai:bob",
186 "#1095: target_agent_id echoes input"
187 );
188 assert_eq!(
189 envelope["from_agent_id"], "ai:alice",
190 "#1095: from_agent_id derived from source row metadata"
191 );
192 assert!(
193 envelope["target_namespace"]
194 .as_str()
195 .unwrap_or("")
196 .starts_with("_shared/"),
197 "#1095: target_namespace begins with _shared/"
198 );
199 }
200
201 /// v0.7.0 #1095 — text-mode output renders a one-line summary
202 /// pointing at the new row. Pins the non-JSON dispatch path.
203 #[test]
204 fn share_cli_text_mode_emits_one_line_summary_1095() {
205 let mut env = TestEnv::fresh();
206 let db = env.db_path.clone();
207 let src_id = seed_memory(&db, "alice/notes", "shared-src-text", "share me");
208 {
209 let conn = db::open(&db).expect("open db for metadata patch");
210 conn.execute(
211 "UPDATE memories SET metadata = json_set(metadata, '$.agent_id', 'ai:alice') WHERE id = ?1",
212 rusqlite::params![src_id],
213 )
214 .expect("patch metadata.agent_id");
215 }
216
217 let args = ShareArgs {
218 memory_id: src_id,
219 target_agent: "ai:bob".to_string(),
220 json: false,
221 };
222 {
223 let mut out = env.output();
224 cmd_share(&db, &args, &mut out).expect("share ok");
225 }
226 let stdout = env.stdout_str();
227 assert!(stdout.starts_with("shared "), "got: {stdout}");
228 assert!(stdout.contains("ai:alice"), "got: {stdout}");
229 assert!(stdout.contains("ai:bob"), "got: {stdout}");
230 assert!(stdout.contains("_shared/"), "got: {stdout}");
231 }
232
233 /// v0.7.0 #1095 — substrate rejection (missing source row) bubbles
234 /// up as a CLI error rather than a panic or silent success. Pins
235 /// the failure envelope from the substrate primitive.
236 #[test]
237 fn share_cli_missing_source_returns_err_1095() {
238 let mut env = TestEnv::fresh();
239 let db = env.db_path.clone();
240 let args = ShareArgs {
241 memory_id: uuid::Uuid::new_v4().to_string(),
242 target_agent: "ai:bob".to_string(),
243 json: true,
244 };
245 let mut out = env.output();
246 let err = cmd_share(&db, &args, &mut out).expect_err("must fail");
247 assert!(err.to_string().contains("not found"), "got: {err}");
248 }
249}