Skip to content
436 changes: 436 additions & 0 deletions crates/vole-cli/src/agent.rs

Large diffs are not rendered by default.

44 changes: 44 additions & 0 deletions crates/vole-cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! vole 命令行入口。
#![forbid(unsafe_code)]

mod agent;
mod clean;
mod clean_group;
mod history_cmd;
Expand Down Expand Up @@ -272,6 +273,31 @@ enum Command {
#[arg(long, conflicts_with = "apply")]
plan_out: Option<PathBuf>,
},
/// List leftover agent containers, sessions, and caches; move selected items to Trash.
///
/// Blockers are shown; you confirm each removal. This is not a git
/// checkout cleaner (`vole worktree` owns checkouts).
///
/// On a TTY with no flags: scan, paginated select (none preselected),
/// confirm, then trash. With `--plan` / `--json`, or when not a TTY:
/// emit a plan only.
Agent {
/// Emit candidates only; do not delete (default when not a TTY; on a TTY skips interactive UI).
#[arg(long, alias = "dry-run", short = 'n', conflicts_with = "apply")]
plan: bool,
/// Apply entries from a plan file (TTL + TOCTOU revalidation required).
#[arg(long, value_name = "PLAN", conflicts_with_all = ["plan", "plan_out"])]
apply: Option<PathBuf>,
/// Permanently delete instead of moving to Trash (`--apply` or after interactive confirm).
#[arg(long)]
permanent: bool,
#[arg(long)]
json: bool,
#[arg(long = "json-stream")]
json_stream: bool,
#[arg(long, conflicts_with = "apply")]
plan_out: Option<PathBuf>,
},
/// Remove stale project build artifacts.
///
/// On a TTY with no flags: paginated select, confirm, then purge.
Expand Down Expand Up @@ -516,6 +542,24 @@ fn main() {
});
std::process::exit(code);
}
Some(Command::Agent {
plan,
apply,
permanent,
json,
json_stream,
plan_out,
}) => {
let code = agent::run_agent(agent::AgentOptions {
explicit_plan: plan,
json,
json_stream,
plan_out,
apply_plan: apply,
permanent,
});
std::process::exit(code);
}
Some(Command::Purge {
plan,
apply,
Expand Down
6 changes: 5 additions & 1 deletion crates/vole-cli/src/tui/home_menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ pub fn map_key(key: KeyEvent) -> Option<HomeKey> {
KeyCode::Esc => Some(HomeKey::Quit),
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => Some(HomeKey::Quit),
KeyCode::Char(c) => match c {
'1'..='6' => Some(HomeKey::Digit(c as u8 - b'0')),
'1'..='7' => Some(HomeKey::Digit(c as u8 - b'0')),
'h' | 'H' => Some(HomeKey::Help),
'v' | 'V' => Some(HomeKey::Version),
't' | 'T' => Some(HomeKey::TouchId),
Expand Down Expand Up @@ -321,6 +321,10 @@ mod tests {
map_key(KeyEvent::new(KeyCode::Char('6'), KeyModifiers::NONE)),
Some(HomeKey::Digit(6))
));
assert!(matches!(
map_key(KeyEvent::new(KeyCode::Char('7'), KeyModifiers::NONE)),
Some(HomeKey::Digit(7))
));
assert!(matches!(
map_key(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)),
Some(HomeKey::Digit(1))
Expand Down
50 changes: 46 additions & 4 deletions crates/vole-cli/src/tui/home_menu_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ pub fn format_home_item_line(index: usize, selected: bool, item: &HomeItem) -> S
)
}

pub const HOME_ITEMS: [HomeItem; 6] = [
pub const HOME_ITEMS: [HomeItem; 7] = [
HomeItem {
title: "Clean",
description: "Free up disk space",
Expand All @@ -43,6 +43,10 @@ pub const HOME_ITEMS: [HomeItem; 6] = [
title: "Worktree",
description: "Remove leftover git worktrees",
},
HomeItem {
title: "Agent",
description: "Remove leftover agent data",
},
];

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand All @@ -53,6 +57,7 @@ pub enum HomeCommand {
Analyze,
Status,
Worktree,
Agent,
TouchId,
Update,
}
Expand All @@ -66,6 +71,7 @@ impl HomeCommand {
Self::Analyze => &["analyze"],
Self::Status => &["status"],
Self::Worktree => &["worktree"],
Self::Agent => &["agent"],
Self::TouchId => &["touchid"],
Self::Update => &["update"],
}
Expand Down Expand Up @@ -120,7 +126,8 @@ impl HomeMenuState {
2 => HomeCommand::Optimize,
3 => HomeCommand::Analyze,
4 => HomeCommand::Status,
_ => HomeCommand::Worktree,
5 => HomeCommand::Worktree,
_ => HomeCommand::Agent,
}
}

Expand All @@ -139,7 +146,7 @@ impl HomeMenuState {
None
}
HomeKey::Enter => Some(HomeAction::Launch(Self::cmd_at(self.cursor))),
HomeKey::Digit(d) if (1..=6).contains(&d) => {
HomeKey::Digit(d) if (1..=7).contains(&d) => {
Some(HomeAction::Launch(Self::cmd_at((d - 1) as usize)))
}
HomeKey::Help => Some(HomeAction::ShowHelp),
Expand Down Expand Up @@ -187,6 +194,10 @@ mod tests {
format_home_item_line(5, false, &HOME_ITEMS[5]),
" 6. Worktree Remove leftover git worktrees"
);
assert_eq!(
format_home_item_line(6, false, &HOME_ITEMS[6]),
" 7. Agent Remove leftover agent data"
);
}

#[test]
Expand All @@ -201,9 +212,11 @@ mod tests {
assert_eq!(HOME_ITEMS[3].description, "Explore disk usage");
assert_eq!(HOME_ITEMS[4].title, "Status");
assert_eq!(HOME_ITEMS[4].description, "Monitor system health");
assert_eq!(HOME_ITEMS.len(), 6);
assert_eq!(HOME_ITEMS.len(), 7);
assert_eq!(HOME_ITEMS[5].title, "Worktree");
assert_eq!(HOME_ITEMS[5].description, "Remove leftover git worktrees");
assert_eq!(HOME_ITEMS[6].title, "Agent");
assert_eq!(HOME_ITEMS[6].description, "Remove leftover agent data");
}

#[test]
Expand Down Expand Up @@ -319,4 +332,33 @@ mod tests {
Some(HomeAction::Launch(HomeCommand::Clean))
);
}

#[test]
fn digit_seven_launches_agent_digits_one_to_six_unchanged() {
let mut st = HomeMenuState::new(HomeMenuConfig {
touchid_configured: true,
show_update: false,
});
assert_eq!(
st.handle_key(HomeKey::Digit(7)),
Some(HomeAction::Launch(HomeCommand::Agent))
);
assert_eq!(HomeCommand::Agent.argv(), &["agent"]);
assert_eq!(
st.handle_key(HomeKey::Digit(6)),
Some(HomeAction::Launch(HomeCommand::Worktree))
);
assert_eq!(
st.handle_key(HomeKey::Digit(1)),
Some(HomeAction::Launch(HomeCommand::Clean))
);
for _ in 0..6 {
assert!(st.handle_key(HomeKey::Down).is_none());
}
assert_eq!(st.cursor(), 6);
assert_eq!(
st.handle_key(HomeKey::Enter),
Some(HomeAction::Launch(HomeCommand::Agent))
);
}
}
116 changes: 116 additions & 0 deletions crates/vole-cli/tests/agent_cli.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
use std::fs;
use std::process::Command;

#[test]
fn agent_help_lists_command_and_avoids_safe_verdict() {
let output = Command::new(env!("CARGO_BIN_EXE_vole"))
.args(["agent", "--help"])
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase();
assert!(stdout.contains("--plan"));
assert!(stdout.contains("--apply"));
assert!(stdout.contains("trash"));
assert!(!stdout.contains("safe to delete"));
assert!(!stdout.contains("deletable"));
}

#[test]
fn plan_json_lists_cache_and_apply_trashes_it() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let cache = home.join(".cursor/Cache");
fs::create_dir_all(&cache).unwrap();
fs::write(cache.join("x"), b"hello").unwrap();
let cwd = home.join("Projects/demo");
fs::create_dir_all(&cwd).unwrap();

let out = Command::new(env!("CARGO_BIN_EXE_vole"))
.env("HOME", home)
.env("VOLE_TIMEOUT_AGENT_SCAN_SEC", "15")
.current_dir(&cwd)
.args(["agent", "--plan", "--json"])
.output()
.unwrap();
assert!(
out.status.success(),
"stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("agent:cache"));
assert!(!stdout.contains("\"safe\""));
let plan: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(plan["schema_version"], 1);

let plan_path = dir.path().join("plan.json");
fs::write(&plan_path, stdout.as_bytes()).unwrap();
let trash = dir.path().join("trash");
fs::create_dir_all(&trash).unwrap();
let apply = Command::new(env!("CARGO_BIN_EXE_vole"))
.env("HOME", home)
.env("MOLE_TEST_TRASH_DIR", &trash)
.current_dir(&cwd)
.args(["agent", "--apply", plan_path.to_str().unwrap()])
.output()
.unwrap();
assert!(
apply.status.success(),
"stderr={}",
String::from_utf8_lossy(&apply.stderr)
);
assert!(!cache.exists(), "cache should be gone");
}

#[test]
fn plan_excludes_worktree_checkout() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
let repo = home.join("Projects/demo");
fs::create_dir_all(&repo).unwrap();
let git = |args: &[&str]| {
let st = Command::new("git")
.args(args)
.current_dir(&repo)
.env("GIT_AUTHOR_NAME", "vole")
.env("GIT_AUTHOR_EMAIL", "vole@test")
.env("GIT_COMMITTER_NAME", "vole")
.env("GIT_COMMITTER_EMAIL", "vole@test")
.status()
.unwrap();
assert!(st.success(), "git {args:?}");
};
git(&["init"]);
fs::write(repo.join("README"), b"x").unwrap();
git(&["add", "README"]);
git(&["commit", "-m", "init"]);
let wt = repo.join(".worktrees/old");
fs::create_dir_all(repo.join(".worktrees")).unwrap();
let st = Command::new("git")
.args(["worktree", "add", "--detach", wt.to_str().unwrap()])
.current_dir(&repo)
.status()
.unwrap();
assert!(st.success());

let out = Command::new(env!("CARGO_BIN_EXE_vole"))
.env("HOME", home)
.current_dir(&repo)
.args(["agent", "--plan", "--json"])
.output()
.unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(!stdout.contains(wt.to_string_lossy().as_ref()));
assert!(!stdout.contains("worktree:"));
}

#[test]
fn top_level_hints_still_rejected() {
let output = Command::new(env!("CARGO_BIN_EXE_vole"))
.args(["hints"])
.output()
.unwrap();
assert!(!output.status.success());
}
2 changes: 2 additions & 0 deletions crates/vole-cli/tests/interactive_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ fn subcommand_help_has_no_mole_mentions() {
"remove",
"completions",
"worktree",
"agent",
] {
let output = Command::new(env!("CARGO_BIN_EXE_vole"))
.args([cmd, "--help"])
Expand Down Expand Up @@ -138,6 +139,7 @@ fn top_level_help_includes_subcommand_options() {
"Usage: vole history",
"Usage: vole completions",
"Usage: vole worktree",
"Usage: vole agent",
] {
assert!(
stdout.contains(needle),
Expand Down
1 change: 1 addition & 0 deletions crates/vole-cli/tests/plan_alias_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const PLAN_COMMANDS: &[&str] = &[
"installer",
"touchid",
"remove",
"agent",
];

#[test]
Expand Down
17 changes: 17 additions & 0 deletions crates/vole-core/src/mutex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ pub fn try_lock_worktree() -> Result<ConfigLock, MutexError> {
try_lock_config("worktree")
}

pub fn try_lock_agent() -> Result<ConfigLock, MutexError> {
try_lock_config("agent")
}

pub fn try_lock_config(name: &str) -> Result<ConfigLock, MutexError> {
let path = cache_dir().join(format!("{}.lock", name));
let file = try_lock_path(&path)?;
Expand Down Expand Up @@ -111,4 +115,17 @@ mod tests {
std::env::remove_var("HOME");
std::fs::remove_dir_all(&dir).ok();
}

#[test]
fn second_agent_lock_fails_nonblocking() {
let _guard = test_env::lock();
let dir = std::env::temp_dir().join(format!("vole-mutex-agent-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
std::env::set_var("HOME", dir.join("home"));
let _a = try_lock_agent().expect("first lock");
let b = try_lock_agent();
assert!(matches!(b, Err(MutexError::Rustix(_))));
std::env::remove_var("HOME");
std::fs::remove_dir_all(&dir).ok();
}
}
Loading
Loading