Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ which = "7"
# CLI
clap = { version = "4", features = ["derive", "env", "wrap_help"] }
is-terminal = "0.4"
indicatif = "0.17"

# TUI
ratatui = "0.29"
Expand Down
1 change: 1 addition & 0 deletions crates/vacuum-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ vacuum-cleaners.workspace = true
vacuum-tui = { path = "../vacuum-tui", version = "0.1.0" }

clap.workspace = true
indicatif.workspace = true
serde.workspace = true
serde_json.workspace = true
jiff.workspace = true
Expand Down
77 changes: 63 additions & 14 deletions crates/vacuum-cli/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,35 @@ impl OutputMode {
}
}

/// Environment variables that signal an AI agent / CI is driving the CLI.
const AGENT_VARS: &[&str] = &[
"AI_AGENT",
"AGENT",
"CI",
"CLAUDECODE",
"CURSOR_AGENT",
"GEMINI_CLI",
];

/// Whether an AI agent or CI environment is detected.
/// Variables that **force** machine output and suppress the TUI: a generic
/// agent, or CI (agentic-cli §4). These are the only behavioral switches.
const AGENT_FORCE_VARS: &[&str] = &["AI_AGENT", "AGENT", "CI"];

/// **Informational** variables that merely name the calling agent. Per
/// agentic-cli §4 they MUST NOT change the output format on their own — they
/// only surface in `metadata.invoking_agent`. TTY / `AGENT_FORCE_VARS` decide
/// the mode.
const AGENT_INFO_VARS: &[&str] = &["CLAUDECODE", "CURSOR_AGENT", "GEMINI_CLI"];

/// Return the first variable in `vars` that is set to a non-empty value,
/// looked up via `get`. Pure over `get` so it can be unit-tested without
/// mutating the process environment.
fn first_set<'a>(vars: &[&'a str], get: impl Fn(&str) -> Option<String>) -> Option<&'a str> {
vars.iter()
.copied()
.find(|var| get(var).is_some_and(|value| !value.is_empty()))
}

/// Whether an AI agent or CI environment is forcing non-interactive output.
pub fn is_agent() -> bool {
AGENT_VARS
.iter()
.any(|var| std::env::var(var).is_ok_and(|value| !value.is_empty()))
first_set(AGENT_FORCE_VARS, |var| std::env::var(var).ok()).is_some()
}

/// The name of the specific invoking agent, if one identified itself, for
/// telemetry in the JSON envelope (`metadata.invoking_agent`). Never a
/// behavioral switch.
pub fn invoking_agent() -> Option<&'static str> {
first_set(AGENT_INFO_VARS, |var| std::env::var(var).ok())
}

/// Resolve the effective output mode from flags, agent detection, and TTY state.
Expand Down Expand Up @@ -74,3 +88,38 @@ pub fn should_launch_tui(global: &GlobalArgs) -> bool {
}
std::io::stdout().is_terminal()
}

#[cfg(test)]
mod tests {
use super::{AGENT_FORCE_VARS, AGENT_INFO_VARS, first_set};

/// Build a fake env lookup from a fixed (name, value) table.
fn env_of<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
move |var| {
pairs
.iter()
.find(|(name, _)| *name == var)
.map(|(_, value)| (*value).to_owned())
}
}

#[test]
fn force_vars_are_detected() {
let env = env_of(&[("AI_AGENT", "1")]);
assert_eq!(first_set(AGENT_FORCE_VARS, &env), Some("AI_AGENT"));
}

#[test]
fn informational_vars_do_not_force_mode() {
// CLAUDECODE et al. must NOT count as a forcing agent (agentic-cli §4).
let env = env_of(&[("CLAUDECODE", "1")]);
assert_eq!(first_set(AGENT_FORCE_VARS, &env), None);
assert_eq!(first_set(AGENT_INFO_VARS, &env), Some("CLAUDECODE"));
}

#[test]
fn empty_value_is_not_set() {
let env = env_of(&[("CI", "")]);
assert_eq!(first_set(AGENT_FORCE_VARS, &env), None);
}
}
101 changes: 83 additions & 18 deletions crates/vacuum-cli/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@

use std::io::Write as _;
use std::path::PathBuf;
use std::time::Duration;

use indicatif::{ProgressBar, ProgressStyle};
use serde::Serialize;
use serde_json::{Value, json};
use vacuum_cleaners::cleaners_for;
Expand Down Expand Up @@ -243,6 +245,78 @@ struct CleanReport {
outcomes: Vec<Outcome>,
}

/// Build a steady-tick spinner for the deletion loop, or `None` when progress
/// must stay silent (machine modes, `--quiet`, dry runs, or no work to do).
///
/// indicatif draws to stderr and auto-hides when stderr is not a terminal, so
/// stdout stays data-only and pipes never see control characters (SFRS §7). The
/// template is uncolored, which trivially satisfies `NO_COLOR`.
fn clean_spinner(len: usize, enabled: bool) -> Option<ProgressBar> {
if !enabled || len == 0 {
return None;
}
let style = ProgressStyle::with_template("{spinner} [{pos}/{len}] {wide_msg}")
.expect("static progress template is valid");
let bar = ProgressBar::new(len as u64).with_style(style);
bar.enable_steady_tick(Duration::from_millis(100));
Some(bar)
}

/// Act on every candidate, returning the outcomes and total reclaimed bytes.
///
/// When `show_progress` is set, a live spinner (above) reports the item being
/// removed so a long deletion does not look frozen. Each candidate is a single
/// blocking removal, so the steady tick — not a percent bar — is what proves the
/// process is alive. Per-item skip warnings are printed above the bar via
/// [`ProgressBar::suspend`] so the spinner line is never corrupted.
fn execute_candidates(
deleter: &Deleter,
candidates: &[Candidate],
delete_mode: DeleteMode,
show_progress: bool,
quiet: bool,
) -> (Vec<Outcome>, u64) {
let verb = if delete_mode == DeleteMode::Purge {
"Removing"
} else {
"Trashing"
};
let progress = clean_spinner(candidates.len(), show_progress);
let mut outcomes = Vec::new();
let mut reclaimed = 0_u64;

for candidate in candidates {
if let Some(bar) = &progress {
bar.set_message(format!("{verb} {}", candidate.label));
}
match deleter.execute(candidate) {
Ok(outcome) => {
if counts_bytes(outcome.action) {
reclaimed += outcome.bytes;
}
outcomes.push(outcome);
}
Err(err) => {
if !quiet {
let warn = format!("vacuum: skipping {}: {err}", candidate.label);
match &progress {
Some(bar) => bar.suspend(|| eprintln!("{warn}")),
None => eprintln!("{warn}"),
}
}
}
}
if let Some(bar) = &progress {
bar.inc(1);
}
}

if let Some(bar) = &progress {
bar.finish_and_clear();
}
(outcomes, reclaimed)
}

/// Run `vacuum clean`.
pub fn run_clean(
args: &CleanArgs,
Expand Down Expand Up @@ -283,24 +357,15 @@ pub fn run_clean(
}

let deleter = Deleter::new(delete_mode, dry_run, resolve_roots(&args.select.roots));
let mut outcomes = Vec::new();
let mut reclaimed = 0_u64;

for candidate in &candidates {
match deleter.execute(candidate) {
Ok(outcome) => {
if counts_bytes(outcome.action) {
reclaimed += outcome.bytes;
}
outcomes.push(outcome);
}
Err(err) => {
if !global.quiet {
eprintln!("vacuum: skipping {}: {err}", candidate.label);
}
}
}
}
// Show a live spinner only for real, interactive work (SFRS §7).
let show_progress = mode == OutputMode::Human && !global.quiet && !dry_run;
let (outcomes, reclaimed) = execute_candidates(
&deleter,
&candidates,
delete_mode,
show_progress,
global.quiet,
);

let report = CleanReport {
dry_run,
Expand Down
5 changes: 5 additions & 0 deletions crates/vacuum-cli/src/envelope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ struct Metadata {
version: &'static str,
command: String,
timestamp: String,
/// The specific agent that invoked us, when one identified itself
/// (agentic-cli §4). Telemetry only — omitted when none is present.
#[serde(skip_serializing_if = "Option::is_none")]
invoking_agent: Option<&'static str>,
maintainer: &'static str,
website: &'static str,
}
Expand Down Expand Up @@ -47,6 +51,7 @@ pub fn emit<T: Serialize>(command: &str, data: T) -> anyhow::Result<()> {
version: env!("CARGO_PKG_VERSION"),
command: command.to_owned(),
timestamp: Timestamp::now().to_string(),
invoking_agent: crate::agent::invoking_agent(),
maintainer: MAINTAINER,
website: WEBSITE,
},
Expand Down
Loading