Improve CLI per clig.dev: TTY-aware color, delete confirmation, JSON consistency
- Gate ANSI color on a global color decision: honor NO_COLOR, TERM=dumb,
and whether stdout is a terminal; add a `--color <auto|always|never>`
flag. Previously keys and tags always emitted escape codes, corrupting
piped/redirected output.
- Require confirmation for destructive deletes. `task delete` and
`backlog delete` now prompt on a TTY and require `--yes`/`-y` when
non-interactive, instead of deleting immediately.
- Honor `--json` for `task move`, `task delete`, and `backlog rebalance`,
which previously printed only human-readable text regardless of the flag.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WXTzKy6U6UByDFzfCXehbc
diff --git a/src/bin/ranger/commands/backlog.rs b/src/bin/ranger/commands/backlog.rs
index eadf3cc..da0eb78 100644
--- a/src/bin/ranger/commands/backlog.rs
+++ b/src/bin/ranger/commands/backlog.rs
@@ -38,6 +38,9 @@ pub enum BacklogCommands {
/// Backlog name
#[arg(add = ArgValueCompleter::new(completions::complete_backlog_names))]
name: String,
+ /// Skip the confirmation prompt
+ #[arg(long, short = 'y')]
+ yes: bool,
},
/// Rebalance task positions in a backlog
Rebalance {
@@ -63,14 +66,32 @@ pub async fn run(
let backlogs = ops::backlog::list(&mut conn).await?;
output::print_list(&backlogs, json, print_backlog);
}
- BacklogCommands::Delete { name } => {
+ BacklogCommands::Delete { name, yes } => {
+ let prompt = format!("Delete backlog '{name}' and all its tasks?");
+ match output::confirm(yes, &prompt) {
+ output::Confirm::Yes => {}
+ output::Confirm::No => {
+ println!("Aborted.");
+ return Ok(());
+ }
+ output::Confirm::NeedsFlag => {
+ return Err(RangerError::Usage(format!(
+ "refusing to delete backlog '{name}' without confirmation; pass --yes to proceed"
+ )));
+ }
+ }
let backlog = ops::backlog::delete(&mut conn, &name).await?;
output::print(&backlog, json, |b| println!("Deleted backlog: {}", b.name));
}
BacklogCommands::Rebalance { name } => {
let backlog = ops::backlog::get_by_name(&mut conn, &name).await?;
let count = ops::task::rebalance(&mut conn, backlog.id).await?;
- println!("Rebalanced {count} tasks in {name}");
+ if json {
+ let value = serde_json::json!({ "backlog": name, "rebalanced": count });
+ println!("{}", serde_json::to_string_pretty(&value).unwrap());
+ } else {
+ println!("Rebalanced {count} tasks in {name}");
+ }
}
BacklogCommands::Show { name, done } => {
let backlog = ops::backlog::get_by_name(&mut conn, &name).await?;
@@ -118,10 +139,8 @@ pub async fn run(
let tag_str = if tags.is_empty() {
String::new()
} else {
- let names: Vec<String> = tags
- .iter()
- .map(|tg| format!("\x1b[36m#{}\x1b[0m", tg.name))
- .collect();
+ let names: Vec<String> =
+ tags.iter().map(|tg| output::format_tag(&tg.name)).collect();
format!(" {}", names.join(" "))
};
println!(
diff --git a/src/bin/ranger/commands/task.rs b/src/bin/ranger/commands/task.rs
index 27d826b..38a7ecb 100644
--- a/src/bin/ranger/commands/task.rs
+++ b/src/bin/ranger/commands/task.rs
@@ -140,6 +140,9 @@ pub enum TaskCommands {
/// Task key or prefix
#[arg(add = ArgValueCompleter::new(completions::complete_task_keys))]
key: String,
+ /// Skip the confirmation prompt
+ #[arg(long, short = 'y')]
+ yes: bool,
},
/// Archive a task
@@ -311,28 +314,48 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
ops::task::move_task(&mut conn, &task, anchors.as_placement()).await?;
let all_keys = ops::task::all_keys(&mut conn).await?;
let prefixes = key::unique_prefix_lengths(&all_keys);
- println!(
- "Moved {} {}",
- output::format_key_from_map(&task.key, &prefixes),
- task.title
- );
+ output::print(&task, json, |t| {
+ println!(
+ "Moved {} {}",
+ output::format_key_from_map(&t.key, &prefixes),
+ t.title
+ );
+ });
}
None => {
return Err(Error::Usage("--before or --after is required".into()));
}
}
}
- TaskCommands::Delete { key } => {
+ TaskCommands::Delete { key, yes } => {
let mut conn = pool.acquire().await?;
let task = ops::task::get_by_key_prefix(&mut conn, &key, backlog_scope).await?;
+
+ let prompt = format!("Delete task '{}'?", task.title);
+ match output::confirm(yes, &prompt) {
+ output::Confirm::Yes => {}
+ output::Confirm::No => {
+ println!("Aborted.");
+ return Ok(());
+ }
+ output::Confirm::NeedsFlag => {
+ return Err(Error::Usage(format!(
+ "refusing to delete task '{}' without confirmation; pass --yes to proceed",
+ task.title
+ )));
+ }
+ }
+
let all_keys = ops::task::all_keys(&mut conn).await?;
let prefixes = key::unique_prefix_lengths(&all_keys);
ops::task::delete(&mut conn, task.id).await?;
- println!(
- "Deleted {} {}",
- output::format_key_from_map(&task.key, &prefixes),
- task.title
- );
+ output::print(&task, json, |t| {
+ println!(
+ "Deleted {} {}",
+ output::format_key_from_map(&t.key, &prefixes),
+ t.title
+ );
+ });
}
TaskCommands::Archive { key } => {
let mut conn = pool.acquire().await?;
diff --git a/src/bin/ranger/main.rs b/src/bin/ranger/main.rs
index 53b3f10..582cd16 100644
--- a/src/bin/ranger/main.rs
+++ b/src/bin/ranger/main.rs
@@ -18,6 +18,10 @@ struct Cli {
#[arg(long, global = true)]
json: bool,
+ /// When to colorize output
+ #[arg(long, value_enum, default_value_t, global = true)]
+ color: output::ColorChoice,
+
/// Path to database file (default: $XDG_DATA_HOME/ranger/ranger.db)
#[arg(long, env = "RANGER_DB", global = true)]
db: Option<PathBuf>,
@@ -92,6 +96,7 @@ fn main() -> miette::Result<()> {
async fn async_main() -> miette::Result<()> {
let cli = Cli::parse();
+ output::init_color(cli.color);
let db_path = resolve_db_path(cli.db);
let pool = ranger::db::connect(&db_path).await?;
diff --git a/src/bin/ranger/output.rs b/src/bin/ranger/output.rs
index 98cfe06..c523229 100644
--- a/src/bin/ranger/output.rs
+++ b/src/bin/ranger/output.rs
@@ -1,5 +1,49 @@
use serde::Serialize;
use std::collections::HashMap;
+use std::io::{BufRead, IsTerminal, Write};
+use std::sync::atomic::{AtomicBool, Ordering};
+
+/// When to colorize human-readable output.
+#[derive(Clone, Copy, Default, clap::ValueEnum)]
+pub enum ColorChoice {
+ /// Colorize when stdout is a terminal (and `NO_COLOR`/`TERM=dumb` are unset).
+ #[default]
+ Auto,
+ /// Always colorize.
+ Always,
+ /// Never colorize.
+ Never,
+}
+
+static USE_COLOR: AtomicBool = AtomicBool::new(false);
+
+/// Resolve and store the global color decision. Call once at startup, before
+/// any output is produced.
+pub fn init_color(choice: ColorChoice) {
+ let enabled = match choice {
+ ColorChoice::Always => true,
+ ColorChoice::Never => false,
+ ColorChoice::Auto => color_auto(),
+ };
+ USE_COLOR.store(enabled, Ordering::Relaxed);
+}
+
+/// Auto-detection per the conventions in <https://clig.dev>: honor `NO_COLOR`,
+/// disable for `TERM=dumb`, and otherwise only colorize a real terminal.
+fn color_auto() -> bool {
+ if std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty()) {
+ return false;
+ }
+ if std::env::var_os("TERM").is_some_and(|v| v == "dumb") {
+ return false;
+ }
+ std::io::stdout().is_terminal()
+}
+
+/// Whether colorized output is currently enabled.
+pub fn use_color() -> bool {
+ USE_COLOR.load(Ordering::Relaxed)
+}
pub fn print<T: Serialize + std::fmt::Debug>(value: &T, json: bool, human: impl FnOnce(&T)) {
if json {
@@ -19,13 +63,53 @@ pub fn print_list<T: Serialize + std::fmt::Debug>(values: &[T], json: bool, huma
}
}
+/// The outcome of a destructive-action confirmation check.
+pub enum Confirm {
+ /// Proceed with the action.
+ Yes,
+ /// The user was prompted and declined.
+ No,
+ /// Not pre-confirmed and not interactive — the caller should require `--yes`.
+ NeedsFlag,
+}
+
+/// Decide whether a destructive action may proceed.
+///
+/// If `yes` is set, proceeds without prompting. Otherwise, prompts on stderr
+/// when stdin is a terminal; when not interactive, returns [`Confirm::NeedsFlag`]
+/// so the caller can tell the user to pass `--yes` (never blocks a script on a
+/// prompt it can't answer).
+pub fn confirm(yes: bool, prompt: &str) -> Confirm {
+ if yes {
+ return Confirm::Yes;
+ }
+ if !std::io::stdin().is_terminal() {
+ return Confirm::NeedsFlag;
+ }
+ eprint!("{prompt} [y/N] ");
+ let _ = std::io::stderr().flush();
+ let mut line = String::new();
+ if std::io::stdin().lock().read_line(&mut line).is_err() {
+ return Confirm::No;
+ }
+ let answer = line.trim().to_ascii_lowercase();
+ if answer == "y" || answer == "yes" {
+ Confirm::Yes
+ } else {
+ Confirm::No
+ }
+}
+
/// The number of key characters to display (matching jj's short change ID style).
const DISPLAY_LEN: usize = 8;
/// Format a key for display: unique prefix in bold, remainder (up to DISPLAY_LEN) in dim.
-/// If the terminal doesn't support colors, returns the plain 8-char prefix.
+/// When color is disabled, returns the plain prefix (up to DISPLAY_LEN chars).
pub fn format_key(key: &str, prefix_len: usize) -> String {
let show = &key[..DISPLAY_LEN.min(key.len())];
+ if !use_color() {
+ return show.to_string();
+ }
let unique = prefix_len.min(show.len());
let bold = &show[..unique];
let dim = &show[unique..];
@@ -37,3 +121,12 @@ pub fn format_key_from_map(key: &str, prefix_lengths: &HashMap<String, usize>) -
let len = prefix_lengths.get(key).copied().unwrap_or(DISPLAY_LEN);
format_key(key, len)
}
+
+/// Format a tag name for display (`#name`), cyan when color is enabled.
+pub fn format_tag(name: &str) -> String {
+ if use_color() {
+ format!("\x1b[36m#{name}\x1b[0m")
+ } else {
+ format!("#{name}")
+ }
+}
diff --git a/tests/cli.rs b/tests/cli.rs
index 8a2395f..b5cb2b4 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -170,7 +170,7 @@ fn full_workflow() {
// Delete a task
let output = ranger(db_path)
- .args(["task", "delete", &t2_key[..4]])
+ .args(["task", "delete", &t2_key[..4], "--yes"])
.output()
.unwrap();
assert!(output.status.success());
@@ -454,7 +454,7 @@ fn full_workflow() {
.output()
.unwrap();
let output = ranger(db_path)
- .args(["backlog", "delete", "Throwaway"])
+ .args(["backlog", "delete", "Throwaway", "--yes"])
.output()
.unwrap();
assert!(output.status.success());
@@ -477,7 +477,7 @@ fn full_workflow() {
// Deleting non-existent backlog fails
let output = ranger(db_path)
- .args(["backlog", "delete", "Nonexistent"])
+ .args(["backlog", "delete", "Nonexistent", "--yes"])
.output()
.unwrap();
assert!(!output.status.success());