From 3a4f205945802ac5cf046c79309c02ed0e3d1647 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 13 May 2025 09:05:10 +1000 Subject: [PATCH] feat: goose cli to track directories, allow to resume projects in location (#2503) --- crates/goose-cli/src/cli.rs | 24 ++ crates/goose-cli/src/commands/mod.rs | 1 + crates/goose-cli/src/commands/project.rs | 307 +++++++++++++++++++++++ crates/goose-cli/src/lib.rs | 1 + crates/goose-cli/src/project_tracker.rs | 146 +++++++++++ crates/goose-cli/src/session/mod.rs | 30 +++ 6 files changed, 509 insertions(+) create mode 100644 crates/goose-cli/src/commands/project.rs create mode 100644 crates/goose-cli/src/project_tracker.rs diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 8cce4d8f..9e448343 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -7,6 +7,7 @@ use crate::commands::bench::agent_generator; use crate::commands::configure::handle_configure; use crate::commands::info::handle_info; use crate::commands::mcp::run_server; +use crate::commands::project::{handle_project_default, handle_projects_interactive}; use crate::commands::recipe::{handle_deeplink, handle_validate}; use crate::commands::session::{handle_session_list, handle_session_remove}; use crate::logging::setup_logging; @@ -250,6 +251,14 @@ enum Command { builtins: Vec, }, + /// Open the last project directory + #[command(about = "Open the last project directory", visible_alias = "p")] + Project {}, + + /// List recent project directories + #[command(about = "List recent project directories", visible_alias = "ps")] + Projects, + /// Execute commands from an instruction file #[command(about = "Execute commands from an instruction file or stdin")] Run { @@ -405,6 +414,11 @@ struct InputConfig { pub async fn cli() -> Result<()> { let cli = Cli::parse(); + // Track the current directory in projects.json + if let Err(e) = crate::project_tracker::update_project_tracker(None, None) { + eprintln!("Warning: Failed to update project tracker: {}", e); + } + match cli.command { Some(Command::Configure {}) => { let _ = handle_configure().await; @@ -468,6 +482,16 @@ pub async fn cli() -> Result<()> { } }; } + Some(Command::Project {}) => { + // Default behavior: offer to resume the last project + handle_project_default()?; + return Ok(()); + } + Some(Command::Projects) => { + // Interactive project selection + handle_projects_interactive()?; + return Ok(()); + } Some(Command::Run { instructions, input_text, diff --git a/crates/goose-cli/src/commands/mod.rs b/crates/goose-cli/src/commands/mod.rs index 30840daa..fdc04a2a 100644 --- a/crates/goose-cli/src/commands/mod.rs +++ b/crates/goose-cli/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod bench; pub mod configure; pub mod info; pub mod mcp; +pub mod project; pub mod recipe; pub mod session; pub mod update; diff --git a/crates/goose-cli/src/commands/project.rs b/crates/goose-cli/src/commands/project.rs new file mode 100644 index 00000000..07ed8c2e --- /dev/null +++ b/crates/goose-cli/src/commands/project.rs @@ -0,0 +1,307 @@ +use anyhow::Result; +use chrono::DateTime; +use cliclack::{self, intro, outro}; +use std::path::Path; + +use crate::project_tracker::ProjectTracker; + +/// Format a DateTime for display +fn format_date(date: DateTime) -> String { + // Format: "2025-05-08 18:15:30" + date.format("%Y-%m-%d %H:%M:%S").to_string() +} + +/// Handle the default project command +/// +/// Offers options to resume the most recently accessed project +pub fn handle_project_default() -> Result<()> { + let tracker = ProjectTracker::load()?; + let mut projects = tracker.list_projects(); + + if projects.is_empty() { + // If no projects exist, just start a new one in the current directory + println!("No previous projects found. Starting a new session in the current directory."); + let mut command = std::process::Command::new("goose"); + command.arg("session"); + let status = command.status()?; + + if !status.success() { + println!("Failed to run Goose. Exit code: {:?}", status.code()); + } + return Ok(()); + } + + // Sort projects by last_accessed (newest first) + projects.sort_by(|a, b| b.last_accessed.cmp(&a.last_accessed)); + + // Get the most recent project + let project = &projects[0]; + let project_dir = &project.path; + + // Check if the directory exists + if !Path::new(project_dir).exists() { + println!( + "Most recent project directory '{}' no longer exists.", + project_dir + ); + return Ok(()); + } + + // Format the path for display + let path = Path::new(project_dir); + let components: Vec<_> = path.components().collect(); + let len = components.len(); + let short_path = if len <= 2 { + project_dir.clone() + } else { + let mut path_str = String::new(); + path_str.push_str("..."); + for component in components.iter().skip(len - 2) { + path_str.push('/'); + path_str.push_str(component.as_os_str().to_string_lossy().as_ref()); + } + path_str + }; + + // Ask the user what they want to do + let _ = intro("Goose Project Manager"); + + let current_dir = std::env::current_dir()?; + let current_dir_display = current_dir.display(); + + let choice = cliclack::select("Choose an option:") + .item( + "resume", + format!("Resume project with session: {}", short_path), + "Continue with the previous session", + ) + .item( + "fresh", + format!("Resume project with fresh session: {}", short_path), + "Change to the project directory but start a new session", + ) + .item( + "new", + format!( + "Start new project in current directory: {}", + current_dir_display + ), + "Stay in the current directory and start a new session", + ) + .interact()?; + + match choice { + "resume" => { + let _ = outro(format!("Changing to directory: {}", project_dir)); + + // Get the session ID if available + let session_id = project.last_session_id.clone(); + + // Change to the project directory + std::env::set_current_dir(project_dir)?; + + // Build the command to run Goose + let mut command = std::process::Command::new("goose"); + command.arg("session"); + + if let Some(id) = session_id { + command.arg("--name").arg(&id).arg("--resume"); + println!("Resuming session: {}", id); + } + + // Execute the command + let status = command.status()?; + + if !status.success() { + println!("Failed to run Goose. Exit code: {:?}", status.code()); + } + } + "fresh" => { + let _ = outro(format!( + "Changing to directory: {} with a fresh session", + project_dir + )); + + // Change to the project directory + std::env::set_current_dir(project_dir)?; + + // Build the command to run Goose with a fresh session + let mut command = std::process::Command::new("goose"); + command.arg("session"); + + // Execute the command + let status = command.status()?; + + if !status.success() { + println!("Failed to run Goose. Exit code: {:?}", status.code()); + } + } + "new" => { + let _ = outro("Starting a new session in the current directory"); + + // Build the command to run Goose + let mut command = std::process::Command::new("goose"); + command.arg("session"); + + // Execute the command + let status = command.status()?; + + if !status.success() { + println!("Failed to run Goose. Exit code: {:?}", status.code()); + } + } + _ => { + let _ = outro("Operation canceled"); + } + } + + Ok(()) +} + +/// Handle the interactive projects command +/// +/// Shows a list of projects and lets the user select one to resume +pub fn handle_projects_interactive() -> Result<()> { + let tracker = ProjectTracker::load()?; + let mut projects = tracker.list_projects(); + + if projects.is_empty() { + println!("No projects found."); + return Ok(()); + } + + // Sort projects by last_accessed (newest first) + projects.sort_by(|a, b| b.last_accessed.cmp(&a.last_accessed)); + + // Format project paths for display + let project_choices: Vec<(String, String)> = projects + .iter() + .enumerate() + .map(|(i, project)| { + let path = Path::new(&project.path); + let components: Vec<_> = path.components().collect(); + let len = components.len(); + let short_path = if len <= 2 { + project.path.clone() + } else { + let mut path_str = String::new(); + path_str.push_str("..."); + for component in components.iter().skip(len - 2) { + path_str.push('/'); + path_str.push_str(component.as_os_str().to_string_lossy().as_ref()); + } + path_str + }; + + // Include last instruction if available (truncated) + let instruction_preview = + project + .last_instruction + .as_ref() + .map_or(String::new(), |instr| { + let truncated = if instr.len() > 40 { + format!("{}...", &instr[0..37]) + } else { + instr.clone() + }; + format!(" [{}]", truncated) + }); + + let formatted_date = format_date(project.last_accessed); + ( + format!("{}", i + 1), // Value to return + format!("{} ({}){}", short_path, formatted_date, instruction_preview), // Display text with instruction + ) + }) + .collect(); + + // Let the user select a project + let _ = intro("Goose Project Manager"); + let mut select = cliclack::select("Select a project:"); + + // Add each project as an option + for (value, display) in &project_choices { + select = select.item(value, display, ""); + } + + // Add a cancel option + let cancel_value = String::from("cancel"); + select = select.item(&cancel_value, "Cancel", "Don't resume any project"); + + let selected = select.interact()?; + + if selected == "cancel" { + let _ = outro("Project selection canceled."); + return Ok(()); + } + + // Parse the selected index + let index = selected.parse::().unwrap_or(0); + if index == 0 || index > projects.len() { + let _ = outro("Invalid selection."); + return Ok(()); + } + + // Get the selected project + let project = &projects[index - 1]; + let project_dir = &project.path; + + // Check if the directory exists + if !Path::new(project_dir).exists() { + let _ = outro(format!( + "Project directory '{}' no longer exists.", + project_dir + )); + return Ok(()); + } + + // Ask if the user wants to resume the session or start a new one + let session_id = project.last_session_id.clone(); + let has_previous_session = session_id.is_some(); + + // Change to the project directory first + std::env::set_current_dir(project_dir)?; + let _ = outro(format!("Changed to directory: {}", project_dir)); + + // Only ask about resuming if there's a previous session + let resume_session = if has_previous_session { + let session_choice = cliclack::select("What would you like to do?") + .item( + "resume", + "Resume previous session", + "Continue with the previous session", + ) + .item( + "new", + "Start new session", + "Start a fresh session in this project directory", + ) + .interact()?; + + session_choice == "resume" + } else { + false + }; + + // Build the command to run Goose + let mut command = std::process::Command::new("goose"); + command.arg("session"); + + if resume_session { + if let Some(id) = session_id { + command.arg("--name").arg(&id).arg("--resume"); + println!("Resuming session: {}", id); + } + } else { + println!("Starting new session"); + } + + // Execute the command + let status = command.status()?; + + if !status.success() { + println!("Failed to run Goose. Exit code: {:?}", status.code()); + } + + Ok(()) +} diff --git a/crates/goose-cli/src/lib.rs b/crates/goose-cli/src/lib.rs index 148f2300..68f2357f 100644 --- a/crates/goose-cli/src/lib.rs +++ b/crates/goose-cli/src/lib.rs @@ -3,6 +3,7 @@ use once_cell::sync::Lazy; pub mod cli; pub mod commands; pub mod logging; +pub mod project_tracker; pub mod recipes; pub mod session; pub mod signal; diff --git a/crates/goose-cli/src/project_tracker.rs b/crates/goose-cli/src/project_tracker.rs new file mode 100644 index 00000000..1b11bbf3 --- /dev/null +++ b/crates/goose-cli/src/project_tracker.rs @@ -0,0 +1,146 @@ +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use etcetera::{choose_app_strategy, AppStrategy}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Structure to track project information +#[derive(Debug, Serialize, Deserialize)] +pub struct ProjectInfo { + /// The absolute path to the project directory + pub path: String, + /// Last time the project was accessed + pub last_accessed: DateTime, + /// Last instruction sent to goose (if available) + pub last_instruction: Option, + /// Last session ID associated with this project + pub last_session_id: Option, +} + +/// Structure to hold all tracked projects +#[derive(Debug, Serialize, Deserialize)] +pub struct ProjectTracker { + projects: HashMap, +} + +/// Project information with path as a separate field for easier access +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectInfoDisplay { + /// The absolute path to the project directory + pub path: String, + /// Last time the project was accessed + pub last_accessed: DateTime, + /// Last instruction sent to goose (if available) + pub last_instruction: Option, + /// Last session ID associated with this project + pub last_session_id: Option, +} + +impl ProjectTracker { + /// Get the path to the projects.json file + fn get_projects_file() -> Result { + let projects_file = choose_app_strategy(crate::APP_STRATEGY.clone()) + .context("goose requires a home dir")? + .in_data_dir("projects.json"); + + // Ensure data directory exists + if let Some(parent) = projects_file.parent() { + if !parent.exists() { + fs::create_dir_all(parent)?; + } + } + + Ok(projects_file) + } + + /// Load the project tracker from the projects.json file + pub fn load() -> Result { + let projects_file = Self::get_projects_file()?; + + if projects_file.exists() { + let file_content = fs::read_to_string(&projects_file)?; + let tracker: ProjectTracker = serde_json::from_str(&file_content) + .context("Failed to parse projects.json file")?; + Ok(tracker) + } else { + // If the file doesn't exist, create a new empty tracker + Ok(ProjectTracker { + projects: HashMap::new(), + }) + } + } + + /// Save the project tracker to the projects.json file + pub fn save(&self) -> Result<()> { + let projects_file = Self::get_projects_file()?; + let json = serde_json::to_string_pretty(self)?; + fs::write(projects_file, json)?; + Ok(()) + } + + /// Update project information for the current directory + /// + /// # Arguments + /// * `project_dir` - The project directory to update + /// * `instruction` - Optional instruction that was sent to goose + /// * `session_id` - Optional session ID associated with this project + pub fn update_project( + &mut self, + project_dir: &Path, + instruction: Option<&str>, + session_id: Option<&str>, + ) -> Result<()> { + let dir_str = project_dir.to_string_lossy().to_string(); + + // Create or update the project entry + let project_info = self.projects.entry(dir_str.clone()).or_insert(ProjectInfo { + path: dir_str, + last_accessed: Utc::now(), + last_instruction: None, + last_session_id: None, + }); + + // Update the last accessed time + project_info.last_accessed = Utc::now(); + + // Update the last instruction if provided + if let Some(instr) = instruction { + project_info.last_instruction = Some(instr.to_string()); + } + + // Update the session ID if provided + if let Some(id) = session_id { + project_info.last_session_id = Some(id.to_string()); + } + + self.save() + } + + /// List all tracked projects + /// + /// Returns a vector of ProjectInfoDisplay objects + pub fn list_projects(&self) -> Vec { + self.projects + .values() + .map(|info| ProjectInfoDisplay { + path: info.path.clone(), + last_accessed: info.last_accessed, + last_instruction: info.last_instruction.clone(), + last_session_id: info.last_session_id.clone(), + }) + .collect() + } +} + +/// Update the project tracker with the current directory and optional instruction +/// +/// # Arguments +/// * `instruction` - Optional instruction that was sent to goose +/// * `session_id` - Optional session ID associated with this project +pub fn update_project_tracker(instruction: Option<&str>, session_id: Option<&str>) -> Result<()> { + let current_dir = std::env::current_dir()?; + let mut tracker = ProjectTracker::load()?; + tracker.update_project(¤t_dir, instruction, session_id) +} diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index e4f5dfdb..97574642 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -290,6 +290,22 @@ impl Session { // Persist messages with provider for automatic description generation session::persist_messages(&self.session_file, &self.messages, Some(provider)).await?; + // Track the current directory and last instruction in projects.json + let session_id = self + .session_file + .file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.to_string()); + + if let Err(e) = + crate::project_tracker::update_project_tracker(Some(&message), session_id.as_deref()) + { + eprintln!( + "Warning: Failed to update project tracker with instruction: {}", + e + ); + } + self.process_agent_response(false).await?; Ok(()) } @@ -356,6 +372,20 @@ impl Session { self.messages.push(Message::user().with_text(&content)); + // Track the current directory and last instruction in projects.json + let session_id = self + .session_file + .file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.to_string()); + + if let Err(e) = crate::project_tracker::update_project_tracker( + Some(&content), + session_id.as_deref(), + ) { + eprintln!("Warning: Failed to update project tracker with instruction: {}", e); + } + // Get the provider from the agent for description generation let provider = self.agent.provider().await?;