@@ -1,6 +1,7 @@
11use std::{
22 env,
33 ffi::OsString,
4+ fs,
45 io::{self, IsTerminal, Write},
56 path::{Path, PathBuf},
67 sync::{Arc, Mutex},
@@ -34,8 +35,10 @@ struct Cli {
3435enum Command {
3536 /// List all sessions
3637 List,
37− /// Delete the current project's .vibebox directory
38− Clean,
38+ /// Reset the current project's .vibebox directory
39+ Reset,
40+ /// Purge the global cache directory
41+ PurgeCache,
3942 /// Explain mounts and mappings
4043 Explain,
4144}
@@ -147,7 +150,7 @@ fn handle_command(command: Command, cwd: &PathBuf, config_override: Option<&Path
147150 tui::render_sessions_table(&rows)?;
148151 Ok(())
149152 }
150− Command::Clean => {
153+ Command::Reset => {
151154 let instance_dir = cwd.join(session_manager::INSTANCE_DIR_NAME);
152155 if !instance_dir.exists() {
153156 println!("No .vibebox directory found at {}", instance_dir.display());
@@ -174,6 +177,34 @@ fn handle_command(command: Command, cwd: &PathBuf, config_override: Option<&Path
174177 );
175178 Ok(())
176179 }
180+ Command::PurgeCache => {
181+ let cache_dir = cache_dir()?;
182+ if !cache_dir.exists() {
183+ println!("No cache directory found at {}", cache_dir.display());
184+ return Ok(());
185+ }
186+ let (file_count, total_bytes) = measure_dir(&cache_dir)?;
187+ let confirmed = Confirm::new()
188+ .with_prompt(format!(
189+ "Delete cache directory {} and all its contents?",
190+ cache_dir.display()
191+ ))
192+ .default(false)
193+ .interact()?;
194+ if !confirmed {
195+ println!("Cancelled.");
196+ return Ok(());
197+ }
198+ fs::remove_dir_all(&cache_dir)?;
199+ println!(
200+ "Purged {} file{} totaling {} from {}",
201+ file_count,
202+ if file_count == 1 { "" } else { "s" },
203+ format_bytes(total_bytes),
204+ cache_dir.display()
205+ );
206+ Ok(())
207+ }
177208 Command::Explain => {
178209 let config = config::load_config_with_path(cwd, config_override);
179210 let mounts = explain::build_mount_rows(cwd, &config)
@@ -212,6 +243,71 @@ fn relative_to_home(directory: &PathBuf) -> String {
212243 directory.display().to_string()
213244}
214245
246+fn cache_dir() -> Result<PathBuf> {
247+ let home = env::var("HOME").map(PathBuf::from)?;
248+ let cache_home = env::var("XDG_CACHE_HOME")
249+ .map(PathBuf::from)
250+ .unwrap_or_else(|_| home.join(".cache"));
251+ Ok(cache_home.join(session_manager::GLOBAL_CACHE_DIR_NAME))
252+}
253+
254+fn measure_dir(path: &Path) -> Result<(u64, u64)> {
255+ let mut total_bytes = 0u64;
256+ let mut file_count = 0u64;
257+ let mut stack = vec![path.to_path_buf()];
258+ while let Some(current) = stack.pop() {
259+ let entries = match fs::read_dir(¤t) {
260+ Ok(entries) => entries,
261+ Err(err) => {
262+ tracing::warn!(path = %current.display(), error = %err, "failed to read directory");
263+ continue;
264+ }
265+ };
266+ for entry in entries {
267+ let entry = match entry {
268+ Ok(entry) => entry,
269+ Err(err) => {
270+ tracing::warn!(path = %current.display(), error = %err, "failed to read directory entry");
271+ continue;
272+ }
273+ };
274+ let path = entry.path();
275+ let metadata = match fs::symlink_metadata(&path) {
276+ Ok(metadata) => metadata,
277+ Err(err) => {
278+ tracing::warn!(path = %path.display(), error = %err, "failed to stat path");
279+ continue;
280+ }
281+ };
282+ let file_type = metadata.file_type();
283+ if file_type.is_dir() {
284+ stack.push(path);
285+ } else {
286+ file_count += 1;
287+ total_bytes = total_bytes.saturating_add(metadata.len());
288+ }
289+ }
290+ }
291+ Ok((file_count, total_bytes))
292+}
293+
294+fn format_bytes(bytes: u64) -> String {
295+ const KB: f64 = 1024.0;
296+ const MB: f64 = KB * 1024.0;
297+ const GB: f64 = MB * 1024.0;
298+
299+ let b = bytes as f64;
300+ if b >= GB {
301+ format!("{:.2} GB", b / GB)
302+ } else if b >= MB {
303+ format!("{:.1} MB", b / MB)
304+ } else if b >= KB {
305+ format!("{:.1} KB", b / KB)
306+ } else {
307+ format!("{} B", bytes)
308+ }
309+}
310+
215311fn format_last_active(value: Option<&str>) -> String {
216312 let Some(raw) = value else {
217313 return "-".to_string();