diff --git a/crates/g3-cli/src/accumulative.rs b/crates/g3-cli/src/accumulative.rs index c68a962..abead16 100644 --- a/crates/g3-cli/src/accumulative.rs +++ b/crates/g3-cli/src/accumulative.rs @@ -308,6 +308,7 @@ async fn handle_command( workspace_dir, cli.new_session, None, // agent_name (not in agent mode) + None, // initial_project (not supported in accumulative mode yet) ) .await?; diff --git a/crates/g3-cli/src/agent_mode.rs b/crates/g3-cli/src/agent_mode.rs index 22ae2db..e03ec04 100644 --- a/crates/g3-cli/src/agent_mode.rs +++ b/crates/g3-cli/src/agent_mode.rs @@ -252,6 +252,7 @@ pub async fn run_agent_mode( &workspace_dir, new_session, Some(agent_name), // agent name for prompt (e.g., "butler>") + None, // initial_project (not supported in agent mode yet) ) .await; } diff --git a/crates/g3-cli/src/cli_args.rs b/crates/g3-cli/src/cli_args.rs index 47bf5a1..e4f50a5 100644 --- a/crates/g3-cli/src/cli_args.rs +++ b/crates/g3-cli/src/cli_args.rs @@ -122,4 +122,8 @@ pub struct Cli { /// Disable automatic memory update reminder at end of agent mode #[arg(long)] pub no_auto_memory: bool, + + /// Load a project from the given path at startup (like /project but without auto-prompt) + #[arg(long, value_name = "PATH")] + pub project: Option, } diff --git a/crates/g3-cli/src/commands.rs b/crates/g3-cli/src/commands.rs index 71b0600..fc40d8b 100644 --- a/crates/g3-cli/src/commands.rs +++ b/crates/g3-cli/src/commands.rs @@ -4,7 +4,6 @@ use anyhow::Result; use rustyline::Editor; -use std::path::PathBuf; use g3_core::ui_writer::UiWriter; use g3_core::Agent; @@ -13,6 +12,7 @@ use crate::completion::G3Helper; use crate::g3_status::{G3Status, Status}; use crate::simple_output::SimpleOutput; use crate::project::Project; +use crate::project::load_and_validate_project; use crate::template::process_template; use crate::task_execution::execute_task_with_retry; @@ -336,33 +336,10 @@ pub async fn handle_command( output.print("Loads project files (brief.md, contacts.yaml, status.md) from the given path."); } else { let project_path_str = parts[1].trim(); - - // Expand tilde if present - let project_path = if project_path_str.starts_with("~/") { - if let Some(home) = dirs::home_dir() { - home.join(&project_path_str[2..]) - } else { - PathBuf::from(project_path_str) - } - } else { - PathBuf::from(project_path_str) - }; - // Validate path is absolute - if !project_path.is_absolute() { - output.print("❌ Project path must be absolute (e.g., /Users/name/projects/myproject)"); - return Ok(true); - } - - // Validate path exists - if !project_path.exists() { - output.print(&format!("❌ Project path does not exist: {}", project_path.display())); - return Ok(true); - } - - // Load the project - match Project::load(&project_path, workspace_dir) { - Some(project) => { + // Use shared helper for validation and loading + match load_and_validate_project(project_path_str, workspace_dir) { + Ok(project) => { // Set project content in agent's system message if agent.set_project_content(Some(project.content.clone())) { // Set project path on UI writer for path shortening @@ -377,10 +354,10 @@ pub async fn handle_command( let project_name = project.path.file_name() .and_then(|n| n.to_str()).unwrap_or("project"); G3Status::loading_project(project_name, &project.format_loaded_status()); - + // Store active project *active_project = Some(project); - + // Auto-submit the project status prompt let prompt = "what is the current state of the project? and what is your suggested next best step?"; execute_task_with_retry(agent, prompt, show_prompt, show_code, output).await; @@ -388,8 +365,8 @@ pub async fn handle_command( output.print("❌ Failed to set project content in agent context."); } } - None => { - output.print("❌ No project files found (brief.md, contacts.yaml, status.md)."); + Err(e) => { + output.print(&format!("❌ {}", e)); } } } diff --git a/crates/g3-cli/src/interactive.rs b/crates/g3-cli/src/interactive.rs index 27120ba..e55a509 100644 --- a/crates/g3-cli/src/interactive.rs +++ b/crates/g3-cli/src/interactive.rs @@ -69,6 +69,7 @@ async fn execute_user_input( /// Run interactive mode with console output. /// If `agent_name` is Some, we're in agent+chat mode: skip session resume/verbose welcome, /// and use the agent name as the prompt (e.g., "butler>"). +/// If `initial_project` is Some, the project is pre-loaded (from --project flag). pub async fn run_interactive( mut agent: Agent, show_prompt: bool, @@ -77,6 +78,7 @@ pub async fn run_interactive( workspace_path: &Path, new_session: bool, agent_name: Option<&str>, + initial_project: Option, ) -> Result<()> { let output = SimpleOutput::new(); let from_agent_mode = agent_name.is_some(); @@ -187,8 +189,22 @@ pub async fn run_interactive( let mut multiline_buffer = String::new(); let mut in_multiline = false; - // Track active project - let mut active_project: Option = None; + // Track active project (may be pre-loaded from --project flag) + let mut active_project: Option = initial_project; + + // If we have an initial project, display its status + if let Some(ref project) = active_project { + let project_name = project.path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("project"); + G3Status::loading_project(project_name, &project.format_loaded_status()); + + // Print newline after the loading message (G3Status::loading_project doesn't add one) + use std::io::Write; + println!(); + std::io::stdout().flush().ok(); + } loop { // Display context window progress bar before each prompt diff --git a/crates/g3-cli/src/lib.rs b/crates/g3-cli/src/lib.rs index 316fc3a..32c68b3 100644 --- a/crates/g3-cli/src/lib.rs +++ b/crates/g3-cli/src/lib.rs @@ -45,6 +45,7 @@ use ui_writer_impl::ConsoleUiWriter; use g3_core::ui_writer::UiWriter; use utils::{initialize_logging, load_config_with_cli_overrides, setup_workspace_directory}; use template::process_template; +use project::load_and_validate_project; pub async fn run() -> Result<()> { let cli = Cli::parse(); @@ -194,6 +195,34 @@ async fn run_console_mode( agent.set_acd_enabled(true); } + // Load CLI project if --project flag was specified + let initial_project: Option = if let Some(ref project_path) = cli.project { + match load_and_validate_project(&project_path.to_string_lossy(), &workspace_dir) { + Ok(cli_project) => { + // Set project content in agent's system message + if agent.set_project_content(Some(cli_project.content.clone())) { + // Set project path on UI writer for path shortening + let project_name = cli_project.path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("project") + .to_string(); + agent.ui_writer().set_project_path(cli_project.path.clone(), project_name); + Some(cli_project) + } else { + eprintln!("Warning: Failed to set project content in agent context."); + None + } + } + Err(e) => { + eprintln!("Error loading project: {}", e); + std::process::exit(1); + } + } + } else { + None + }; + if cli.autonomous { let _agent = run_autonomous( agent, @@ -232,6 +261,7 @@ async fn run_console_mode( project.workspace(), cli.new_session, None, // agent_name (not in agent mode) + initial_project, ) .await } diff --git a/crates/g3-cli/src/project.rs b/crates/g3-cli/src/project.rs index b75a9c9..e16d554 100644 --- a/crates/g3-cli/src/project.rs +++ b/crates/g3-cli/src/project.rs @@ -3,6 +3,7 @@ //! Projects allow loading context from a specific project directory that persists //! in the system message and survives compaction/dehydration. +use anyhow::{anyhow, Result}; use std::path::{Path, PathBuf}; /// Represents an active project with its loaded content. @@ -96,6 +97,45 @@ impl Project { } } +/// Load and validate a project from a path string. +/// +/// This is the shared logic used by both `--project` CLI flag and `/project` command. +/// It handles: +/// - Tilde expansion for home directory +/// - Validation that path is absolute +/// - Validation that path exists +/// - Loading project files +/// +/// Returns the loaded Project or an error with a user-friendly message. +pub fn load_and_validate_project(project_path_str: &str, workspace_dir: &Path) -> Result { + // Expand tilde if present + let project_path = if project_path_str.starts_with("~/") { + if let Some(home) = dirs::home_dir() { + home.join(&project_path_str[2..]) + } else { + PathBuf::from(project_path_str) + } + } else { + PathBuf::from(project_path_str) + }; + + // Validate path is absolute + if !project_path.is_absolute() { + return Err(anyhow!( + "Project path must be absolute (e.g., /Users/name/projects/myproject)" + )); + } + + // Validate path exists + if !project_path.exists() { + return Err(anyhow!("Project path does not exist: {}", project_path.display())); + } + + // Load the project + Project::load(&project_path, workspace_dir) + .ok_or_else(|| anyhow!("No project files found (brief.md, contacts.yaml, status.md)")) +} + #[cfg(test)] mod tests { use super::*; @@ -191,4 +231,60 @@ mod tests { assert!(project.is_none()); } + + #[test] + fn test_load_and_validate_project_success() { + let workspace = TempDir::new().unwrap(); + let project_dir = TempDir::new().unwrap(); + + // Create project files + fs::write(project_dir.path().join("brief.md"), "Project brief").unwrap(); + + let result = load_and_validate_project( + project_dir.path().to_str().unwrap(), + workspace.path(), + ); + + assert!(result.is_ok()); + let project = result.unwrap(); + assert!(project.loaded_files.contains(&"brief.md".to_string())); + } + + #[test] + fn test_load_and_validate_project_relative_path_error() { + let workspace = TempDir::new().unwrap(); + + let result = load_and_validate_project("relative/path", workspace.path()); + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("must be absolute")); + } + + #[test] + fn test_load_and_validate_project_nonexistent_path_error() { + let workspace = TempDir::new().unwrap(); + + let result = load_and_validate_project("/nonexistent/path/12345", workspace.path()); + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("does not exist")); + } + + #[test] + fn test_load_and_validate_project_no_files_error() { + let workspace = TempDir::new().unwrap(); + let project_dir = TempDir::new().unwrap(); + + // No project files created + let result = load_and_validate_project( + project_dir.path().to_str().unwrap(), + workspace.path(), + ); + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("No project files found")); + } }