diff --git a/AGENTS.md b/AGENTS.md index 077a2fb..2553856 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,7 @@ Instructions for AI coding agents working with this codebase. ## Code Style - Do not use emojis in code, output, or documentation. Unicode symbols (✓, ✗, →, ⚠) are acceptable. +- CLI colored output uses `cli/src/color.rs`. This module respects the `NO_COLOR` environment variable. Never use hardcoded ANSI color codes. diff --git a/cli/src/color.rs b/cli/src/color.rs new file mode 100644 index 0000000..07eb526 --- /dev/null +++ b/cli/src/color.rs @@ -0,0 +1,158 @@ +//! Color output utilities respecting NO_COLOR environment variable. +//! +//! When the NO_COLOR environment variable is present (regardless of value), +//! all color formatting is disabled per https://no-color.org/ + +use std::env; +use std::sync::OnceLock; + +/// Returns true if color output is enabled (NO_COLOR is NOT set) +pub fn is_enabled() -> bool { + static COLORS_ENABLED: OnceLock = OnceLock::new(); + *COLORS_ENABLED.get_or_init(|| env::var("NO_COLOR").is_err()) +} + +/// Format text in red (errors) +pub fn red(text: &str) -> String { + if is_enabled() { + format!("\x1b[31m{}\x1b[0m", text) + } else { + text.to_string() + } +} + +/// Format text in green (success) +pub fn green(text: &str) -> String { + if is_enabled() { + format!("\x1b[32m{}\x1b[0m", text) + } else { + text.to_string() + } +} + +/// Format text in yellow (warnings) +pub fn yellow(text: &str) -> String { + if is_enabled() { + format!("\x1b[33m{}\x1b[0m", text) + } else { + text.to_string() + } +} + +/// Format text in cyan (info/progress) +pub fn cyan(text: &str) -> String { + if is_enabled() { + format!("\x1b[36m{}\x1b[0m", text) + } else { + text.to_string() + } +} + +/// Format text in bold +pub fn bold(text: &str) -> String { + if is_enabled() { + format!("\x1b[1m{}\x1b[0m", text) + } else { + text.to_string() + } +} + +/// Format text in dim +pub fn dim(text: &str) -> String { + if is_enabled() { + format!("\x1b[2m{}\x1b[0m", text) + } else { + text.to_string() + } +} + +/// Red X error indicator +pub fn error_indicator() -> &'static str { + static INDICATOR: OnceLock = OnceLock::new(); + INDICATOR.get_or_init(|| { + if is_enabled() { + "\x1b[31m✗\x1b[0m".to_string() + } else { + "✗".to_string() + } + }) +} + +/// Green checkmark success indicator +pub fn success_indicator() -> &'static str { + static INDICATOR: OnceLock = OnceLock::new(); + INDICATOR.get_or_init(|| { + if is_enabled() { + "\x1b[32m✓\x1b[0m".to_string() + } else { + "✓".to_string() + } + }) +} + +/// Yellow warning indicator +pub fn warning_indicator() -> &'static str { + static INDICATOR: OnceLock = OnceLock::new(); + INDICATOR.get_or_init(|| { + if is_enabled() { + "\x1b[33m⚠\x1b[0m".to_string() + } else { + "⚠".to_string() + } + }) +} + +/// Get console log color prefix by level +pub fn console_level_prefix(level: &str) -> String { + if !is_enabled() { + return format!("[{}]", level); + } + + let color = match level { + "error" => "\x1b[31m", + "warning" => "\x1b[33m", + "info" => "\x1b[36m", + _ => "", + }; + if color.is_empty() { + format!("[{}]", level) + } else { + format!("{}[{}]\x1b[0m", color, level) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_red_contains_ansi_codes() { + // Test the format structure (actual color depends on NO_COLOR env) + let formatted = format!("\x1b[31m{}\x1b[0m", "error"); + assert!(formatted.contains("\x1b[31m")); + assert!(formatted.contains("\x1b[0m")); + } + + #[test] + fn test_green_contains_ansi_codes() { + let formatted = format!("\x1b[32m{}\x1b[0m", "success"); + assert!(formatted.contains("\x1b[32m")); + } + + #[test] + fn test_console_level_prefix_contains_level() { + // Regardless of color state, the level text should be present + assert!(console_level_prefix("error").contains("error")); + assert!(console_level_prefix("warning").contains("warning")); + assert!(console_level_prefix("info").contains("info")); + assert!(console_level_prefix("log").contains("log")); + } + + #[test] + fn test_indicators_contain_symbols() { + // Regardless of color state, symbols should be present + assert!(error_indicator().contains('✗')); + assert!(success_indicator().contains('✓')); + assert!(warning_indicator().contains('⚠')); + } +} diff --git a/cli/src/install.rs b/cli/src/install.rs index f92c1fc..09b5929 100644 --- a/cli/src/install.rs +++ b/cli/src/install.rs @@ -1,3 +1,4 @@ +use crate::color; use std::process::{exit, Command, Stdio}; pub fn run_install(with_deps: bool) { @@ -5,7 +6,7 @@ pub fn run_install(with_deps: bool) { if is_linux { if with_deps { - println!("\x1b[36mInstalling system dependencies...\x1b[0m"); + println!("{}", color::cyan("Installing system dependencies...")); let (pkg_mgr, deps) = if which_exists("apt-get") { ( @@ -93,7 +94,7 @@ pub fn run_install(with_deps: bool) { ], ) } else { - eprintln!("\x1b[31m✗\x1b[0m No supported package manager found (apt-get, dnf, or yum)"); + eprintln!("{} No supported package manager found (apt-get, dnf, or yum)", color::error_indicator()); exit(1); }; @@ -112,22 +113,23 @@ pub fn run_install(with_deps: bool) { match status { Ok(s) if s.success() => { - println!("\x1b[32m✓\x1b[0m System dependencies installed") + println!("{} System dependencies installed", color::success_indicator()) } Ok(_) => eprintln!( - "\x1b[33m⚠\x1b[0m Failed to install some dependencies. You may need to run manually with sudo." + "{} Failed to install some dependencies. You may need to run manually with sudo.", + color::warning_indicator() ), - Err(e) => eprintln!("\x1b[33m⚠\x1b[0m Could not run install command: {}", e), + Err(e) => eprintln!("{} Could not run install command: {}", color::warning_indicator(), e), } } else { - println!("\x1b[33m⚠\x1b[0m Linux detected. If browser fails to launch, run:"); + println!("{} Linux detected. If browser fails to launch, run:", color::warning_indicator()); println!(" agent-browser install --with-deps"); println!(" or: npx playwright install-deps chromium"); println!(); } } - println!("\x1b[36mInstalling Chromium browser...\x1b[0m"); + println!("{}", color::cyan("Installing Chromium browser...")); // On Windows, we need to use cmd.exe to run npx because npx is actually npx.cmd // and Command::new() doesn't resolve .cmd files the way the shell does. @@ -144,23 +146,23 @@ pub fn run_install(with_deps: bool) { match status { Ok(s) if s.success() => { - println!("\x1b[32m✓\x1b[0m Chromium installed successfully"); + println!("{} Chromium installed successfully", color::success_indicator()); if is_linux && !with_deps { println!(); - println!("\x1b[33mNote:\x1b[0m If you see \"shared library\" errors when running, use:"); + println!("{} If you see \"shared library\" errors when running, use:", color::yellow("Note:")); println!(" agent-browser install --with-deps"); } } Ok(_) => { - eprintln!("\x1b[31m✗\x1b[0m Failed to install browser"); + eprintln!("{} Failed to install browser", color::error_indicator()); if is_linux { - println!("\x1b[33mTip:\x1b[0m Try installing system dependencies first:"); + println!("{} Try installing system dependencies first:", color::yellow("Tip:")); println!(" agent-browser install --with-deps"); } exit(1); } Err(e) => { - eprintln!("\x1b[31m✗\x1b[0m Failed to run npx: {}", e); + eprintln!("{} Failed to run npx: {}", color::error_indicator(), e); eprintln!("Make sure Node.js is installed and npx is in your PATH"); exit(1); } diff --git a/cli/src/main.rs b/cli/src/main.rs index 09b1ed7..86b070d 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,3 +1,4 @@ +mod color; mod commands; mod connection; mod flags; @@ -107,7 +108,7 @@ fn run_session(args: &[String], session: &str, json_mode: bool) { } else { println!("Active sessions:"); for s in &sessions { - let marker = if s == session { "→" } else { " " }; + let marker = if s == session { color::cyan("→") } else { " ".to_string() }; println!("{} {}", marker, s); } } @@ -179,7 +180,7 @@ fn main() { error_type ); } else { - eprintln!("\x1b[31m{}\x1b[0m", e.format()); + eprintln!("{}", color::red(&e.format())); } exit(1); } @@ -191,7 +192,7 @@ fn main() { if flags.json { println!(r#"{{"success":false,"error":"{}"}}"#, e); } else { - eprintln!("\x1b[31m✗\x1b[0m {}", e); + eprintln!("{} {}", color::error_indicator(), e); } exit(1); } @@ -201,10 +202,10 @@ fn main() { if daemon_result.already_running && (flags.executable_path.is_some() || !flags.extensions.is_empty()) { if !flags.json { if flags.executable_path.is_some() { - eprintln!("\x1b[33m⚠\x1b[0m --executable-path ignored: daemon already running. Use 'agent-browser close' first to restart with new path."); + eprintln!("{} --executable-path ignored: daemon already running. Use 'agent-browser close' first to restart with new path.", color::warning_indicator()); } if !flags.extensions.is_empty() { - eprintln!("\x1b[33m⚠\x1b[0m --extension ignored: daemon already running. Use 'agent-browser close' first to restart with extensions."); + eprintln!("{} --extension ignored: daemon already running. Use 'agent-browser close' first to restart with extensions.", color::warning_indicator()); } } } @@ -217,7 +218,7 @@ fn main() { if flags.json { println!(r#"{{"success":false,"error":"{}"}}"#, msg); } else { - eprintln!("\x1b[31m✗\x1b[0m {}", msg); + eprintln!("{} {}", color::error_indicator(), msg); } exit(1); } @@ -226,7 +227,7 @@ fn main() { if flags.json { println!(r#"{{"success":false,"error":"{}"}}"#, msg); } else { - eprintln!("\x1b[31m✗\x1b[0m {}", msg); + eprintln!("{} {}", color::error_indicator(), msg); } exit(1); } @@ -236,7 +237,7 @@ fn main() { if flags.json { println!(r#"{{"success":false,"error":"{}"}}"#, msg); } else { - eprintln!("\x1b[31m✗\x1b[0m {}", msg); + eprintln!("{} {}", color::error_indicator(), msg); } exit(1); } @@ -258,7 +259,7 @@ fn main() { if flags.json { println!(r#"{{"success":false,"error":"{}"}}"#, msg); } else { - eprintln!("\x1b[31m✗\x1b[0m {}", msg); + eprintln!("{} {}", color::error_indicator(), msg); } exit(1); } @@ -281,7 +282,7 @@ fn main() { if let Err(e) = send_command(launch_cmd, &flags.session) { if !flags.json { - eprintln!("\x1b[33m⚠\x1b[0m Could not configure browser: {}", e); + eprintln!("{} Could not configure browser: {}", color::warning_indicator(), e); } } } @@ -298,7 +299,7 @@ fn main() { if flags.json { println!(r#"{{"success":false,"error":"{}"}}"#, e); } else { - eprintln!("\x1b[31m✗\x1b[0m {}", e); + eprintln!("{} {}", color::error_indicator(), e); } exit(1); } diff --git a/cli/src/output.rs b/cli/src/output.rs index 9e8ed8b..c6fca30 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -1,3 +1,4 @@ +use crate::color; use crate::connection::Response; pub fn print_response(resp: &Response, json_mode: bool) { @@ -8,7 +9,8 @@ pub fn print_response(resp: &Response, json_mode: bool) { if !resp.success { eprintln!( - "\x1b[31m✗\x1b[0m {}", + "{} {}", + color::error_indicator(), resp.error.as_deref().unwrap_or("Unknown error") ); return; @@ -18,8 +20,8 @@ pub fn print_response(resp: &Response, json_mode: bool) { // Navigation response if let Some(url) = data.get("url").and_then(|v| v.as_str()) { if let Some(title) = data.get("title").and_then(|v| v.as_str()) { - println!("\x1b[32m✓\x1b[0m \x1b[1m{}\x1b[0m", title); - println!("\x1b[2m {}\x1b[0m", url); + println!("{} {}", color::success_indicator(), color::bold(title)); + println!(" {}", color::dim(url)); return; } println!("{}", url); @@ -85,7 +87,7 @@ pub fn print_response(resp: &Response, json_mode: bool) { .unwrap_or("Untitled"); let url = tab.get("url").and_then(|v| v.as_str()).unwrap_or(""); let active = tab.get("active").and_then(|v| v.as_bool()).unwrap_or(false); - let marker = if active { "→" } else { " " }; + let marker = if active { color::cyan("→") } else { " ".to_string() }; println!("{} [{}] {} - {}", marker, i, title, url); } return; @@ -95,13 +97,7 @@ pub fn print_response(resp: &Response, json_mode: bool) { for log in logs { let level = log.get("type").and_then(|v| v.as_str()).unwrap_or("log"); let text = log.get("text").and_then(|v| v.as_str()).unwrap_or(""); - let color = match level { - "error" => "\x1b[31m", - "warning" => "\x1b[33m", - "info" => "\x1b[36m", - _ => "\x1b[0m", - }; - println!("{}[{}]\x1b[0m {}", color, level, text); + println!("{} {}", color::console_level_prefix(level), text); } return; } @@ -109,7 +105,7 @@ pub fn print_response(resp: &Response, json_mode: bool) { if let Some(errors) = data.get("errors").and_then(|v| v.as_array()) { for err in errors { let msg = err.get("message").and_then(|v| v.as_str()).unwrap_or(""); - println!("\x1b[31m✗\x1b[0m {}", msg); + println!("{} {}", color::error_indicator(), msg); } return; } @@ -166,16 +162,16 @@ pub fn print_response(resp: &Response, json_mode: bool) { } // Closed if data.get("closed").is_some() { - println!("\x1b[32m✓\x1b[0m Browser closed"); + println!("{} Browser closed", color::success_indicator()); return; } // Recording start (has "started" field) if let Some(started) = data.get("started").and_then(|v| v.as_bool()) { if started { if let Some(path) = data.get("path").and_then(|v| v.as_str()) { - println!("\x1b[32m✓\x1b[0m Recording started: {}", path); + println!("{} Recording started: {}", color::success_indicator(), path); } else { - println!("\x1b[32m✓\x1b[0m Recording started"); + println!("{} Recording started", color::success_indicator()); } return; } @@ -184,9 +180,9 @@ pub fn print_response(resp: &Response, json_mode: bool) { if data.get("stopped").is_some() { let path = data.get("path").and_then(|v| v.as_str()).unwrap_or("unknown"); if let Some(prev_path) = data.get("previousPath").and_then(|v| v.as_str()) { - println!("\x1b[32m✓\x1b[0m Recording restarted: {} (previous saved to {})", path, prev_path); + println!("{} Recording restarted: {} (previous saved to {})", color::success_indicator(), path, prev_path); } else { - println!("\x1b[32m✓\x1b[0m Recording started: {}", path); + println!("{} Recording started: {}", color::success_indicator(), path); } return; } @@ -194,18 +190,18 @@ pub fn print_response(resp: &Response, json_mode: bool) { if data.get("frames").is_some() { if let Some(path) = data.get("path").and_then(|v| v.as_str()) { if let Some(error) = data.get("error").and_then(|v| v.as_str()) { - println!("\x1b[33m⚠\x1b[0m Recording saved to {} - {}", path, error); + println!("{} Recording saved to {} - {}", color::warning_indicator(), path, error); } else { - println!("\x1b[32m✓\x1b[0m Recording saved to {}", path); + println!("{} Recording saved to {}", color::success_indicator(), path); } } else { - println!("\x1b[32m✓\x1b[0m Recording stopped"); + println!("{} Recording stopped", color::success_indicator()); } return; } // Screenshot path (no "started" or "frames" field) if let Some(path) = data.get("path").and_then(|v| v.as_str()) { - println!("\x1b[32m✓\x1b[0m Screenshot saved to {}", path); + println!("{} Screenshot saved to {}", color::success_indicator(), color::green(path)); return; } // Screenshot base64 @@ -214,7 +210,7 @@ pub fn print_response(resp: &Response, json_mode: bool) { return; } // Default success - println!("\x1b[32m✓\x1b[0m Done"); + println!("{} Done", color::success_indicator()); } }