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
change
commit 6cdf0bc1196e725acbb4334522d1206fba87b5cf
author Claude <noreply@anthropic.com>
date
parent zvpupsmm
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());