feat: add NO_COLOR environment variable support (#122)
Add a centralized color module (cli/src/color.rs) that respects the NO_COLOR environment variable per https://no-color.org/ Changes: - Add color.rs module with helper functions for colored output - Refactor all hardcoded ANSI escape codes to use the color module - Add tests for color formatting functions - Update AGENTS.md with color module usage guidelines Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
28740acecf
commit
c88734da89
@@ -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.
|
||||
|
||||
<!-- opensrc:start -->
|
||||
|
||||
|
||||
@@ -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<bool> = 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<String> = 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<String> = 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<String> = 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('⚠'));
|
||||
}
|
||||
}
|
||||
+14
-12
@@ -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);
|
||||
}
|
||||
|
||||
+12
-11
@@ -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);
|
||||
}
|
||||
|
||||
+18
-22
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user