1use anyhow::Result;
2use clap::ValueEnum;
3use serde::Serialize;
4use serde_json::Value;
5use std::io::{self, IsTerminal};
6use std::sync::OnceLock;
7
8pub fn set_agent_name(name: &str) {
10 let sanitized = sanitize_agent_name(name);
11 if !sanitized.is_empty() {
12 let _ = ACTIVE_AGENT_NAME.set(sanitized);
13 }
14}
15
16pub fn current_agent_name() -> Option<String> {
18 ACTIVE_AGENT_NAME.get().cloned()
19}
20
21static ACTIVE_AGENT_NAME: OnceLock<String> = OnceLock::new();
22
23fn sanitize_agent_name(raw: &str) -> String {
24 raw.chars()
25 .map(|c| {
26 if c == '/' || c == '\\' {
27 '_'
28 } else if c.is_control() || c.is_whitespace() {
29 '-'
30 } else {
31 c
32 }
33 })
34 .collect::<String>()
35 .trim_matches('-')
36 .to_string()
37}
38
39pub fn cents_to_brl(cents: i64) -> String {
40 format!("R$ {:.2}", cents as f64 / 100.0)
41}
42
43#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, ValueEnum, Default)]
44#[serde(rename_all = "kebab-case")]
45pub enum OutputFormat {
46 #[default]
48 Text,
49 Json,
51}
52
53#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
54#[serde(rename_all = "kebab-case")]
55pub enum CommandName {
56 Signup,
58 Login,
60 Verify,
62 Logout,
64 Whoami,
66 Account,
68 Balance,
70 Deposit,
72 PixList,
74 PixSend,
76 BrcodeDecode,
78 History,
80 Limits,
82 McpServe,
84 SetupMcp,
86 Update,
88}
89
90impl CommandName {
91 pub fn label(&self) -> &'static str {
92 match self {
93 Self::Signup => "signup",
94 Self::Login => "login",
95 Self::Verify => "verify",
96 Self::Logout => "logout",
97 Self::Whoami => "whoami",
98 Self::Account => "account",
99 Self::Balance => "balance",
100 Self::Deposit => "deposit",
101 Self::PixList => "pix-list",
102 Self::PixSend => "pix-send",
103 Self::BrcodeDecode => "brcode-decode",
104 Self::History => "history",
105 Self::Limits => "limits",
106 Self::McpServe => "mcp serve",
107 Self::SetupMcp => "setup mcp",
108 Self::Update => "update",
109 }
110 }
111
112 pub fn requires_receipt(&self) -> bool {
113 matches!(self, Self::PixSend)
114 }
115}
116
117#[derive(Serialize)]
118pub struct OutputEnvelope {
119 pub command: CommandName,
120 pub data: Value,
121}
122
123const COLOR_RESET: &str = "\x1b[0m";
124const COLOR_BOLD: &str = "\x1b[1m";
125const COLOR_DIM: &str = "\x1b[2m";
126const COLOR_CYAN: &str = "\x1b[36m";
127const COLOR_GREEN: &str = "\x1b[32m";
128const COLOR_YELLOW: &str = "\x1b[33m";
129const COLOR_RED: &str = "\x1b[31m";
130
131pub(crate) fn truncate_text(value: &str, max: usize) -> String {
132 if max == 0 {
133 return String::new();
134 }
135
136 if value.chars().count() <= max {
137 return value.to_string();
138 }
139
140 let mut shortened = value
141 .chars()
142 .take(max.saturating_sub(1))
143 .collect::<String>();
144 if max == 1 {
145 shortened.clear();
146 }
147 shortened.push('…');
148 shortened
149}
150
151pub enum StatusColor {
153 Ok,
155 Warn,
157 Info,
159 Err,
161}
162
163impl StatusColor {
164 fn ansi(&self) -> &'static str {
165 match self {
166 Self::Ok => COLOR_GREEN,
167 Self::Warn => COLOR_YELLOW,
168 Self::Info => COLOR_CYAN,
169 Self::Err => COLOR_RED,
170 }
171 }
172}
173
174pub fn emit_status(tag: &str, message: &str, color: StatusColor) {
176 if io::stdout().is_terminal() {
177 let c = color.ansi();
178 println!("> {c}{COLOR_BOLD}[{tag}]{COLOR_RESET} {message}");
179 } else {
180 println!("> [{tag}] {message}");
181 }
182}
183
184pub fn emit_line(message: &str) {
186 if io::stdout().is_terminal() {
187 println!("> {COLOR_DIM}{message}{COLOR_RESET}");
188 } else {
189 println!("> {message}");
190 }
191}
192
193pub fn emit_message(
194 command: CommandName,
195 message: &str,
196 output: OutputFormat,
197 quiet: bool,
198) -> Result<()> {
199 if quiet {
200 return Ok(());
201 }
202
203 match output {
204 OutputFormat::Text => {
205 emit_status("OK", message, StatusColor::Ok);
206 }
207 OutputFormat::Json => {
208 let envelope = OutputEnvelope {
209 command,
210 data: Value::String(message.to_string()),
211 };
212 let json = serde_json::to_string_pretty(&envelope)?;
213 println!("{json}");
214 }
215 }
216 Ok(())
217}
218
219pub fn emit_data<T>(
220 command: CommandName,
221 payload: &T,
222 output: OutputFormat,
223 quiet: bool,
224) -> Result<()>
225where
226 T: Serialize,
227{
228 if quiet {
229 return Ok(());
230 }
231
232 match output {
233 OutputFormat::Text => {
234 let payload = serde_json::to_value(payload)?;
235 let text = match payload {
236 Value::Object(values) => {
237 if command.requires_receipt() {
238 render_receipt(command.label(), &values)
239 } else {
240 render_kv(command.label(), &values)
241 }
242 }
243 _ => serde_json::to_string_pretty(&payload)?,
244 };
245 println!("{text}");
246 }
247 OutputFormat::Json => {
248 let envelope = OutputEnvelope {
249 command,
250 data: serde_json::to_value(payload)?,
251 };
252 let json = serde_json::to_string_pretty(&envelope)?;
253 println!("{json}");
254 }
255 }
256 Ok(())
257}
258
259fn render_receipt(command_label: &str, values: &serde_json::Map<String, Value>) -> String {
260 let rows: Vec<(String, String)> = values
261 .iter()
262 .map(|(key, value)| (key.to_string(), render_value(value)))
263 .collect();
264
265 let max_key = rows
266 .iter()
267 .map(|(key, _)| key.len())
268 .max()
269 .unwrap_or(5)
270 .max(5);
271 let max_value = rows
272 .iter()
273 .map(|(_, value)| value.len())
274 .max()
275 .unwrap_or(5)
276 .max(5);
277 let title = format!("AGENT.PAY // {}", command_label.to_uppercase());
278 let key_width = max_key.max(title.len()).max(12);
279 let value_width = max_value.max(12);
280
281 let mut output = String::new();
282 output.push_str(&format!(
283 "┌{}┬{}┐\n",
284 "─".repeat(key_width + 2),
285 "─".repeat(value_width + 2)
286 ));
287 output.push_str(&format!(
288 "│ {:^key_width$} │ {:^value_width$} │\n",
289 title, "VALUE"
290 ));
291 output.push_str(&format!(
292 "├{}┼{}┤\n",
293 "─".repeat(key_width + 2),
294 "─".repeat(value_width + 2)
295 ));
296 output.push_str(&format!(
297 "│ {:^key_width$} │ {:^value_width$} │\n",
298 "FIELD", "CONTENT"
299 ));
300 output.push_str(&format!(
301 "├{}┼{}┤\n",
302 "─".repeat(key_width + 2),
303 "─".repeat(value_width + 2)
304 ));
305
306 for (index, (key, value)) in rows.iter().enumerate() {
307 output.push_str(&format!(
308 "│ {:<key_width$} │ {:<value_width$} │\n",
309 key, value,
310 ));
311 if index + 1 < rows.len() {
312 output.push_str(&format!(
313 "├{}┼{}┤\n",
314 "─".repeat(key_width + 2),
315 "─".repeat(value_width + 2)
316 ));
317 }
318 }
319
320 output.push_str(&format!(
321 "└{}┴{}┘",
322 "─".repeat(key_width + 2),
323 "─".repeat(value_width + 2)
324 ));
325 output
326}
327
328fn render_value(value: &Value) -> String {
329 match value {
330 Value::Null => "null".into(),
331 Value::Bool(value) => value.to_string(),
332 Value::Number(value) => value.to_string(),
333 Value::String(value) => value.clone(),
334 Value::Array(values) => {
335 if values.is_empty() {
336 "[]".into()
337 } else if values.len() <= 4 && values.iter().all(is_simple_scalar) {
338 let values = values
339 .iter()
340 .map(render_value)
341 .collect::<Vec<_>>()
342 .join(", ");
343 format!("[{values}]")
344 } else {
345 format!("[{} items]", values.len())
346 }
347 }
348 Value::Object(values) => format!("{{{} fields}}", values.len()),
349 }
350}
351
352fn style_kv_key(value: &str) -> String {
353 if io::stdout().is_terminal() {
354 format!("{COLOR_BOLD}{COLOR_CYAN}{value}{COLOR_RESET}")
355 } else {
356 value.to_string()
357 }
358}
359
360fn style_kv_value(value: &str) -> String {
361 if io::stdout().is_terminal() {
362 format!("{COLOR_GREEN}{value}{COLOR_RESET}")
363 } else {
364 value.to_string()
365 }
366}
367
368fn render_kv(command_label: &str, values: &serde_json::Map<String, Value>) -> String {
369 if values.is_empty() {
370 return format!("{}: no data", command_label);
371 }
372
373 let max_key = values.keys().map(|key| key.len()).max().unwrap_or(4).max(4);
374
375 let mut output = String::new();
376 for (index, (key, value)) in values.iter().enumerate() {
377 if index > 0 {
378 output.push('\n');
379 }
380 let padded_key = format!("{:width$}", key, width = max_key);
381 output.push_str(&format!(
382 "{}: {}",
383 style_kv_key(&padded_key),
384 style_kv_value(&render_value(value)),
385 ));
386 }
387 output
388}
389
390fn is_simple_scalar(value: &Value) -> bool {
391 matches!(
392 value,
393 Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_)
394 )
395}