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:
Ryan Daigle
2026-01-17 20:37:00 -06:00
committed by GitHub
co-authored by Claude Opus 4.5
parent 28740acecf
commit c88734da89
5 changed files with 203 additions and 45 deletions
+1
View File
@@ -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 -->
+158
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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());
}
}