Skip to main content

ai_memory/cli/
agents.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! `cmd_agents` and `cmd_pending` migrations. See `cli::store` for the
5//! design pattern.
6
7use crate::cli::CliOutput;
8use crate::cli::helpers::id_short;
9use crate::{db, identity, validate};
10use anyhow::Result;
11use clap::{Args, Subcommand};
12use std::path::Path;
13
14#[derive(Args)]
15pub struct AgentsArgs {
16    #[command(subcommand)]
17    pub action: Option<AgentsAction>,
18}
19
20#[derive(Subcommand)]
21pub enum AgentsAction {
22    /// List registered agents (default)
23    List,
24    /// Register or refresh an agent
25    Register {
26        /// Agent identifier
27        #[arg(long)]
28        agent_id: String,
29        /// Agent type. Curated values: human, system, ai:claude-opus-4.6,
30        /// ai:claude-opus-4.7, ai:codex-5.4, ai:grok-4.2. Any `ai:<name>`
31        /// form is also accepted (e.g. `ai:gpt-5`, `ai:gemini-2.5`) —
32        /// red-team #235.
33        #[arg(long)]
34        agent_type: String,
35        /// Comma-separated capability tags
36        #[arg(long, default_value = "")]
37        capabilities: String,
38    },
39}
40
41#[derive(Args)]
42pub struct PendingArgs {
43    #[command(subcommand)]
44    pub action: PendingAction,
45}
46
47#[derive(Subcommand)]
48pub enum PendingAction {
49    /// List pending actions (optionally filter by status).
50    List {
51        #[arg(long)]
52        status: Option<String>,
53        #[arg(long, default_value_t = 100)]
54        limit: usize,
55    },
56    /// Approve a pending action by id.
57    Approve { id: String },
58    /// Reject a pending action by id.
59    Reject { id: String },
60}
61
62/// `agents` handler.
63pub fn run_agents(
64    db_path: &Path,
65    args: AgentsArgs,
66    json_out: bool,
67    out: &mut CliOutput<'_>,
68) -> Result<()> {
69    let conn = db::open(db_path)?;
70    match args.action.unwrap_or(AgentsAction::List) {
71        AgentsAction::List => {
72            let agents = db::list_agents(&conn)?;
73            if json_out {
74                writeln!(
75                    out.stdout,
76                    "{}",
77                    serde_json::json!({"count": agents.len(), "agents": agents})
78                )?;
79            } else if agents.is_empty() {
80                writeln!(out.stdout, "no registered agents")?;
81            } else {
82                for a in &agents {
83                    let caps = if a.capabilities.is_empty() {
84                        String::new()
85                    } else {
86                        format!(" [{}]", a.capabilities.join(","))
87                    };
88                    writeln!(
89                        out.stdout,
90                        "{}  type={}  registered={}  last_seen={}{}",
91                        a.agent_id, a.agent_type, a.registered_at, a.last_seen_at, caps
92                    )?;
93                }
94                writeln!(out.stdout, "{} registered agents", agents.len())?;
95            }
96        }
97        AgentsAction::Register {
98            agent_id,
99            agent_type,
100            capabilities,
101        } => {
102            validate::validate_agent_id(&agent_id)?;
103            validate::validate_agent_type(&agent_type)?;
104            let caps: Vec<String> = capabilities
105                .split(',')
106                .map(str::trim)
107                .filter(|s| !s.is_empty())
108                .map(String::from)
109                .collect();
110            validate::validate_capabilities(&caps)?;
111            let id = db::register_agent(&conn, &agent_id, &agent_type, &caps)?;
112            if json_out {
113                writeln!(
114                    out.stdout,
115                    "{}",
116                    serde_json::json!({
117                        "registered": true,
118                        "id": id,
119                        "agent_id": agent_id,
120                        "agent_type": agent_type,
121                        "capabilities": caps,
122                    })
123                )?;
124            } else {
125                writeln!(
126                    out.stdout,
127                    "registered {agent_id} (type={agent_type}, capabilities={})",
128                    if caps.is_empty() {
129                        "-".to_string()
130                    } else {
131                        caps.join(",")
132                    }
133                )?;
134            }
135        }
136    }
137    Ok(())
138}
139
140/// `pending` handler.
141pub fn run_pending(
142    db_path: &Path,
143    args: PendingArgs,
144    json_out: bool,
145    cli_agent_id: Option<&str>,
146    out: &mut CliOutput<'_>,
147) -> Result<()> {
148    let conn = db::open(db_path)?;
149    match args.action {
150        PendingAction::List { status, limit } => {
151            let items = db::list_pending_actions(&conn, status.as_deref(), limit)?;
152            if json_out {
153                writeln!(
154                    out.stdout,
155                    "{}",
156                    serde_json::json!({"count": items.len(), "pending": items})
157                )?;
158            } else if items.is_empty() {
159                writeln!(out.stdout, "no pending actions")?;
160            } else {
161                for item in &items {
162                    writeln!(
163                        out.stdout,
164                        "[{}] {} ns={} action={} by={} ({})",
165                        id_short(&item.id),
166                        item.status,
167                        item.namespace,
168                        item.action_type,
169                        item.requested_by,
170                        item.requested_at
171                    )?;
172                }
173                writeln!(out.stdout, "{} pending action(s)", items.len())?;
174            }
175        }
176        PendingAction::Approve { id } => {
177            use db::ApproveOutcome;
178            validate::validate_id(&id)?;
179            let agent = identity::resolve_agent_id(cli_agent_id, None)?;
180            match db::approve_with_approver_type(&conn, &id, &agent)? {
181                ApproveOutcome::Approved => {
182                    let executed = db::execute_pending_action(&conn, &id)?;
183                    if json_out {
184                        writeln!(
185                            out.stdout,
186                            "{}",
187                            serde_json::json!({
188                                "approved": true,
189                                "id": id,
190                                "decided_by": agent,
191                                "executed": true,
192                                "memory_id": executed,
193                            })
194                        )?;
195                    } else {
196                        writeln!(out.stdout, "approved + executed: {id} (by {agent})")?;
197                    }
198                }
199                ApproveOutcome::Pending { votes, quorum } => {
200                    if json_out {
201                        writeln!(
202                            out.stdout,
203                            "{}",
204                            serde_json::json!({
205                                "approved": false,
206                                "status": "pending",
207                                "id": id,
208                                "votes": votes,
209                                "quorum": quorum,
210                                "reason": "consensus threshold not yet reached",
211                            })
212                        )?;
213                    } else {
214                        writeln!(
215                            out.stdout,
216                            "approval recorded: {id} ({votes}/{quorum} consensus, not yet met)"
217                        )?;
218                    }
219                }
220                ApproveOutcome::Rejected(reason) => {
221                    writeln!(out.stderr, "approve rejected: {reason}")?;
222                    std::process::exit(1);
223                }
224            }
225        }
226        PendingAction::Reject { id } => {
227            validate::validate_id(&id)?;
228            let agent = identity::resolve_agent_id(cli_agent_id, None)?;
229            let ok = db::decide_pending_action(&conn, &id, false, &agent)?;
230            if !ok {
231                writeln!(
232                    out.stderr,
233                    "pending action not found or already decided: {id}"
234                )?;
235                std::process::exit(1);
236            }
237            if json_out {
238                writeln!(
239                    out.stdout,
240                    "{}",
241                    serde_json::json!({"rejected": true, "id": id, "decided_by": agent})
242                )?;
243            } else {
244                writeln!(out.stdout, "rejected: {id} (by {agent})")?;
245            }
246        }
247    }
248    Ok(())
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use crate::cli::test_utils::TestEnv;
255
256    #[test]
257    fn test_agents_list_empty() {
258        let mut env = TestEnv::fresh();
259        let db = env.db_path.clone();
260        let args = AgentsArgs {
261            action: Some(AgentsAction::List),
262        };
263        {
264            let mut out = env.output();
265            run_agents(&db, args, false, &mut out).unwrap();
266        }
267        assert!(env.stdout_str().contains("no registered agents"));
268    }
269
270    #[test]
271    fn test_agents_list_empty_json() {
272        let mut env = TestEnv::fresh();
273        let db = env.db_path.clone();
274        let args = AgentsArgs {
275            action: Some(AgentsAction::List),
276        };
277        {
278            let mut out = env.output();
279            run_agents(&db, args, true, &mut out).unwrap();
280        }
281        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
282        assert_eq!(v["count"].as_u64().unwrap(), 0);
283    }
284
285    #[test]
286    fn test_agents_register_happy_path() {
287        let mut env = TestEnv::fresh();
288        let db = env.db_path.clone();
289        let args = AgentsArgs {
290            action: Some(AgentsAction::Register {
291                agent_id: "agent-1".to_string(),
292                agent_type: "human".to_string(),
293                capabilities: "alpha,beta".to_string(),
294            }),
295        };
296        {
297            let mut out = env.output();
298            run_agents(&db, args, false, &mut out).unwrap();
299        }
300        assert!(env.stdout_str().contains("registered agent-1"));
301    }
302
303    #[test]
304    fn test_agents_register_then_list() {
305        let mut env = TestEnv::fresh();
306        let db = env.db_path.clone();
307        let reg = AgentsArgs {
308            action: Some(AgentsAction::Register {
309                agent_id: "agent-2".to_string(),
310                agent_type: "system".to_string(),
311                capabilities: String::new(),
312            }),
313        };
314        {
315            let mut out = env.output();
316            run_agents(&db, reg, false, &mut out).unwrap();
317        }
318        env.stdout.clear();
319        env.stderr.clear();
320        let list = AgentsArgs {
321            action: Some(AgentsAction::List),
322        };
323        {
324            let mut out = env.output();
325            run_agents(&db, list, false, &mut out).unwrap();
326        }
327        let s = env.stdout_str();
328        assert!(s.contains("agent-2"));
329        assert!(s.contains("type=system"));
330    }
331
332    #[test]
333    fn test_agents_register_invalid_agent_id() {
334        let mut env = TestEnv::fresh();
335        let db = env.db_path.clone();
336        let args = AgentsArgs {
337            action: Some(AgentsAction::Register {
338                agent_id: String::new(), // empty -> validation error
339                agent_type: "human".to_string(),
340                capabilities: String::new(),
341            }),
342        };
343        let mut out = env.output();
344        let res = run_agents(&db, args, false, &mut out);
345        assert!(res.is_err());
346    }
347
348    #[test]
349    fn test_agents_default_action_is_list() {
350        let mut env = TestEnv::fresh();
351        let db = env.db_path.clone();
352        let args = AgentsArgs { action: None };
353        {
354            let mut out = env.output();
355            run_agents(&db, args, false, &mut out).unwrap();
356        }
357        assert!(env.stdout_str().contains("no registered agents"));
358    }
359
360    #[test]
361    fn test_pending_list_empty() {
362        let mut env = TestEnv::fresh();
363        let db = env.db_path.clone();
364        let args = PendingArgs {
365            action: PendingAction::List {
366                status: None,
367                limit: 100,
368            },
369        };
370        {
371            let mut out = env.output();
372            run_pending(&db, args, false, Some("test-agent"), &mut out).unwrap();
373        }
374        assert!(env.stdout_str().contains("no pending actions"));
375    }
376
377    #[test]
378    fn test_pending_list_empty_json() {
379        let mut env = TestEnv::fresh();
380        let db = env.db_path.clone();
381        let args = PendingArgs {
382            action: PendingAction::List {
383                status: Some("pending".to_string()),
384                limit: 100,
385            },
386        };
387        {
388            let mut out = env.output();
389            run_pending(&db, args, true, Some("test-agent"), &mut out).unwrap();
390        }
391        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
392        assert_eq!(v["count"].as_u64().unwrap(), 0);
393    }
394}