Skip to main content

schwab_cli/commands/
agent.rs

1use std::fs;
2use std::io::{stdout, Write};
3use std::path::PathBuf;
4
5use anyhow::{Context, Result};
6use serde_json::json;
7
8use crate::agent::{
9    analysis_report, compute_stats, load_agent_state, load_sim_agent_state, load_state, log_path,
10    pid_path, reset_sim, run_agent_loop, save_state, sim_journal_path, sim_state_path,
11    spawn_background, state_summary, stop_daemon,
12};
13use crate::cli::{AgentCommands, AgentSimCommands};
14use crate::config::RuntimeConfig;
15use crate::flatten::{build_agent_options_flatten_plan, execute_agent_options_flatten};
16use crate::output::{OutputFormat, ResponseEnvelope};
17use crate::rules::{rules_json_schema, RulesConfig};
18use crate::ui::context::DashboardContext;
19use crate::ui::dashboard::render_dashboard;
20use crate::ui::discover::resolve_rules_file;
21
22pub async fn run(runtime: &RuntimeConfig, command: AgentCommands) -> Result<()> {
23    match command {
24        AgentCommands::Schema => {
25            runtime.emit(ResponseEnvelope::ok("agent schema", rules_json_schema()));
26        }
27        AgentCommands::Validate { file } => {
28            let rules = RulesConfig::load(&file)?;
29            runtime.emit(ResponseEnvelope::ok(
30                "agent validate",
31                json!({
32                    "valid": true,
33                    "agent_id": rules.agent_id,
34                    "accounts": rules.accounts.len(),
35                    "watchlist": rules.watchlist,
36                    "llm_enabled": rules.llm.enabled,
37                    "telegram_enabled": rules.notify.telegram.enabled,
38                    "simulation": rules.simulation,
39                }),
40            ));
41        }
42        AgentCommands::Status { rules_file } => {
43            if runtime.output == OutputFormat::Json {
44                if let Some(rules_path) = rules_file {
45                    let ctx = DashboardContext::load(&rules_path)?;
46                    runtime.emit(ResponseEnvelope::ok("agent status", ctx.to_json()));
47                } else {
48                    emit_legacy_status(runtime)?;
49                }
50            } else if let Some(rules_path) = rules_file {
51                let ctx = DashboardContext::load(&rules_path)?;
52                print!("{}", render_dashboard(&ctx));
53                stdout().flush().ok();
54            } else if let Ok(rules_path) = resolve_rules_file(None, runtime.is_interactive()) {
55                let ctx = DashboardContext::load(&rules_path)?;
56                print!("{}", render_dashboard(&ctx));
57                stdout().flush().ok();
58            } else {
59                emit_legacy_status(runtime)?;
60            }
61        }
62        AgentCommands::Run {
63            file,
64            once,
65            background,
66        } => {
67            if background {
68                if once {
69                    anyhow::bail!("--background cannot be combined with --once");
70                }
71                let mut extra = Vec::new();
72                if runtime.dry_run {
73                    extra.push("--dry-run".into());
74                }
75                if runtime.simulate {
76                    extra.push("--simulate".into());
77                }
78                if runtime.trust {
79                    extra.push("--trust".into());
80                }
81                if runtime.yes {
82                    extra.push("--yes".into());
83                }
84                if runtime.output == OutputFormat::Json {
85                    extra.push("--json".into());
86                }
87                let pid = spawn_background(&file, &extra)?;
88                runtime.emit(ResponseEnvelope::ok(
89                    "agent background",
90                    json!({
91                        "pid": pid,
92                        "pid_file": pid_path(&file),
93                        "log_file": log_path(&file),
94                        "rules": file,
95                        "simulate": runtime.simulate,
96                    }),
97                ));
98                return Ok(());
99            }
100            run_agent_loop(runtime, &file, once, None).await?;
101        }
102        AgentCommands::Stop { file } => {
103            stop_daemon(&file)?;
104            runtime.emit(ResponseEnvelope::ok(
105                "agent stop",
106                json!({ "stopped": true, "rules": file }),
107            ));
108        }
109        AgentCommands::CloseAll { file } => {
110            let rules = RulesConfig::load(&file)?;
111            let state = if runtime.simulate {
112                load_sim_agent_state(&file, &rules.agent_id)
113            } else {
114                load_agent_state(&file, &rules.agent_id)
115            };
116            let api = runtime.build_api()?;
117            let plan = build_agent_options_flatten_plan(&api, &rules, &state).await?;
118            let data = execute_agent_options_flatten(runtime, &api, &file, &rules, &plan).await?;
119            runtime.emit(ResponseEnvelope::ok("agent close-all", data));
120        }
121        AgentCommands::Sim { command } => run_sim(runtime, command).await?,
122    }
123    Ok(())
124}
125
126async fn run_sim(runtime: &RuntimeConfig, command: AgentSimCommands) -> Result<()> {
127    match command {
128        AgentSimCommands::Stats { file } => {
129            let rules = RulesConfig::load(&file)?;
130            let mut state = load_sim_agent_state(&file, &rules.agent_id);
131            state.agent_id = rules.agent_id.clone();
132            let stats = compute_stats(&state, &rules);
133            runtime.emit(ResponseEnvelope::ok(
134                "agent sim stats",
135                json!({
136                    "rules": file,
137                    "state_path": sim_state_path(&file),
138                    "stats": stats,
139                }),
140            ));
141        }
142        AgentSimCommands::Report { file, output } => {
143            let rules = RulesConfig::load(&file)?;
144            let mut state = load_sim_agent_state(&file, &rules.agent_id);
145            state.agent_id = rules.agent_id.clone();
146            let report = analysis_report(&state, &rules);
147            if let Some(path) = output {
148                let text = serde_json::to_string_pretty(&report)?;
149                fs::write(&path, text).with_context(|| format!("write report {}", path.display()))?;
150                runtime.emit(ResponseEnvelope::ok(
151                    "agent sim report",
152                    json!({ "rules": file, "output": path }),
153                ));
154            } else {
155                runtime.emit(ResponseEnvelope::ok("agent sim report", report));
156            }
157        }
158        AgentSimCommands::Reset { file } => {
159            if !runtime.yes {
160                anyhow::bail!("sim reset requires --yes (live agent-state is not modified)");
161            }
162            let rules = RulesConfig::load(&file)?;
163            let path = sim_state_path(&file);
164            let mut state = load_sim_agent_state(&file, &rules.agent_id);
165            state.agent_id = rules.agent_id.clone();
166            reset_sim(&mut state, &rules);
167            save_state(&path, &state)?;
168            let journal = sim_journal_path(&file);
169            if journal.exists() {
170                fs::remove_file(&journal)
171                    .with_context(|| format!("remove journal {}", journal.display()))?;
172            }
173            runtime.emit(ResponseEnvelope::ok(
174                "agent sim reset",
175                json!({
176                    "rules": file,
177                    "state_path": path,
178                    "starting_budget_usd": state
179                        .sim
180                        .as_ref()
181                        .map(|s| s.starting_budget_usd),
182                }),
183            ));
184        }
185    }
186    Ok(())
187}
188
189fn emit_legacy_status(runtime: &RuntimeConfig) -> Result<()> {
190    let path = directories::ProjectDirs::from("com", "schwabinvestbot", "schwab")
191        .map(|d| d.data_local_dir().join("agent-state.json"))
192        .unwrap_or_else(|| PathBuf::from("agent-state.json"));
193    let state = load_state(&path)?;
194    runtime.emit(ResponseEnvelope::ok(
195        "agent status",
196        json!({ "state_path": path, "state": state_summary(&state) }),
197    ));
198    Ok(())
199}