mirror of
https://github.com/aljazceru/goose.git
synced 2026-02-23 15:34:27 +01:00
feat: goose cli to track directories, allow to resume projects in location (#2503)
This commit is contained in:
@@ -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<String>,
|
||||
},
|
||||
|
||||
/// 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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
307
crates/goose-cli/src/commands/project.rs
Normal file
307
crates/goose-cli/src/commands/project.rs
Normal file
@@ -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<chrono::Utc>) -> 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::<usize>().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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
146
crates/goose-cli/src/project_tracker.rs
Normal file
146
crates/goose-cli/src/project_tracker.rs
Normal file
@@ -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<Utc>,
|
||||
/// Last instruction sent to goose (if available)
|
||||
pub last_instruction: Option<String>,
|
||||
/// Last session ID associated with this project
|
||||
pub last_session_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Structure to hold all tracked projects
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ProjectTracker {
|
||||
projects: HashMap<String, ProjectInfo>,
|
||||
}
|
||||
|
||||
/// 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<Utc>,
|
||||
/// Last instruction sent to goose (if available)
|
||||
pub last_instruction: Option<String>,
|
||||
/// Last session ID associated with this project
|
||||
pub last_session_id: Option<String>,
|
||||
}
|
||||
|
||||
impl ProjectTracker {
|
||||
/// Get the path to the projects.json file
|
||||
fn get_projects_file() -> Result<PathBuf> {
|
||||
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<Self> {
|
||||
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<ProjectInfoDisplay> {
|
||||
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)
|
||||
}
|
||||
@@ -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?;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user