diff --git a/cortex-cli/src/acp_cmd.rs b/cortex-cli/src/acp_cmd.rs index 8014a175..e938514b 100644 --- a/cortex-cli/src/acp_cmd.rs +++ b/cortex-cli/src/acp_cmd.rs @@ -4,15 +4,19 @@ //! Supports both stdio and HTTP transports for flexible integration. use anyhow::{Result, bail}; -use clap::Parser; +use clap::{Parser, Subcommand}; use cortex_common::resolve_model_alias; use std::net::SocketAddr; use std::path::PathBuf; /// ACP server CLI command. #[derive(Debug, Parser)] -#[command(about = "Start an ACP (Agent Client Protocol) server for IDE integration")] +#[command(about = "ACP (Agent Client Protocol) server for IDE integration")] pub struct AcpCli { + #[command(subcommand)] + pub command: Option, + + // Default server arguments (when no subcommand is provided) /// Working directory for the session. #[arg(long = "cwd", short = 'C', value_name = "DIR")] pub cwd: Option, @@ -53,70 +57,312 @@ pub struct AcpCli { pub deny_tools: Vec, } +/// ACP subcommands. +#[derive(Debug, Subcommand)] +pub enum AcpSubcommand { + /// Start an ACP server (default behavior). + Start(AcpStartArgs), + + /// Check the status of ACP servers. + /// Shows running ACP servers, their ports/transports, and connected clients. + Status(AcpStatusArgs), +} + +/// Arguments for the ACP start subcommand. +#[derive(Debug, Parser)] +pub struct AcpStartArgs { + /// Working directory for the session. + #[arg(long = "cwd", short = 'C', value_name = "DIR")] + pub cwd: Option, + + /// Port to listen on (default: random available port). + #[arg(long = "port", short = 'p', default_value = "0")] + pub port: u16, + + /// Host address to bind to. + #[arg(long = "host", default_value = "127.0.0.1")] + pub host: String, + + /// Use stdio transport (JSON-RPC over stdin/stdout). + #[arg(long = "stdio", conflicts_with_all = ["port", "host"])] + pub stdio: bool, + + /// Enable verbose/debug output. + #[arg(long = "verbose", short = 'v')] + pub verbose: bool, + + /// Model to use. + #[arg(long = "model", short = 'm')] + pub model: Option, + + /// Agent to use. + #[arg(long = "agent")] + pub agent: Option, + + /// Tools to allow (whitelist). + #[arg(long = "allow-tool", action = clap::ArgAction::Append)] + pub allow_tools: Vec, + + /// Tools to deny (blacklist). + #[arg(long = "deny-tool", action = clap::ArgAction::Append)] + pub deny_tools: Vec, +} + +/// Arguments for the ACP status subcommand. +#[derive(Debug, Parser)] +pub struct AcpStatusArgs { + /// Output status in JSON format. + #[arg(long)] + pub json: bool, + + /// Enable verbose output with additional details. + #[arg(long = "verbose", short = 'v')] + pub verbose: bool, +} + impl AcpCli { - /// Run the ACP server command. + /// Run the ACP command. pub async fn run(self) -> Result<()> { - // Validate agent exists early if specified (Issue #1958) - if let Some(ref agent_name) = self.agent { - let registry = cortex_engine::AgentRegistry::new(); - // Scan for agents in standard locations - let _ = registry.scan().await; - if !registry.exists(agent_name).await { - bail!( - "Agent not found: '{}'. Use 'cortex agent list' to see available agents.", - agent_name - ); + match self.command { + Some(AcpSubcommand::Start(args)) => run_server(args).await, + Some(AcpSubcommand::Status(args)) => run_status(args).await, + None => { + // Default behavior: start a server with top-level args + let args = AcpStartArgs { + cwd: self.cwd, + port: self.port, + host: self.host, + stdio: self.stdio, + verbose: self.verbose, + model: self.model, + agent: self.agent, + allow_tools: self.allow_tools, + deny_tools: self.deny_tools, + }; + run_server(args).await } } + } +} - // Build configuration - let mut config = cortex_engine::Config::default(); - - if let Some(cwd) = &self.cwd { - config.cwd = cwd.clone(); +/// Run the ACP server. +async fn run_server(args: AcpStartArgs) -> Result<()> { + // Validate agent exists early if specified (Issue #1958) + if let Some(ref agent_name) = args.agent { + let registry = cortex_engine::AgentRegistry::new(); + // Scan for agents in standard locations + let _ = registry.scan().await; + if !registry.exists(agent_name).await { + bail!( + "Agent not found: '{}'. Use 'cortex agent list' to see available agents.", + agent_name + ); } + } - if let Some(model) = &self.model { - // Resolve model alias (e.g., "sonnet" -> "anthropic/claude-sonnet-4-20250514") - config.model = resolve_model_alias(model).to_string(); - } + // Build configuration + let mut config = cortex_engine::Config::default(); + + if let Some(cwd) = &args.cwd { + config.cwd = cwd.clone(); + } + + if let Some(model) = &args.model { + // Resolve model alias (e.g., "sonnet" -> "anthropic/claude-sonnet-4-20250514") + config.model = resolve_model_alias(model).to_string(); + } + + // Report tool restrictions (will be applied when server initializes session) + if !args.allow_tools.is_empty() { + eprintln!("Tool whitelist: {:?}", args.allow_tools); + } + + if !args.deny_tools.is_empty() { + eprintln!("Tool blacklist: {:?}", args.deny_tools); + } + + // Decide transport mode + if args.stdio || args.port == 0 { + // Use stdio transport + eprintln!("Starting ACP server on stdio transport..."); + let server = cortex_engine::acp::AcpServer::new(config); + server.run_stdio().await + } else { + // Use HTTP transport + let addr: SocketAddr = format!("{}:{}", args.host, args.port).parse()?; + eprintln!("Starting ACP server on http://{}", addr); + let server = cortex_engine::acp::AcpServer::new(config); + server.run_http(addr).await + } +} + +/// Check the status of ACP servers. +async fn run_status(args: AcpStatusArgs) -> Result<()> { + use serde::Serialize; + use std::process::Command; + + #[derive(Serialize)] + struct AcpStatus { + running: bool, + servers: Vec, + } + + #[derive(Serialize)] + struct AcpServerInfo { + pid: u32, + port: Option, + transport: String, + uptime: Option, + } + + // Try to find running ACP server processes + let mut servers = Vec::new(); + + // On Unix-like systems, use pgrep to find cortex acp processes + #[cfg(unix)] + { + if let Ok(output) = Command::new("pgrep").args(["-f", "cortex.*acp"]).output() { + if output.status.success() { + let pids = String::from_utf8_lossy(&output.stdout); + for pid_str in pids.lines() { + if let Ok(pid) = pid_str.trim().parse::() { + // Try to get more details about the process + let port = get_process_listening_port(pid); + let transport = if port.is_some() { "http" } else { "stdio" }; - // Report tool restrictions (will be applied when server initializes session) - if !self.allow_tools.is_empty() { - eprintln!("Tool whitelist: {:?}", self.allow_tools); - // Note: Tool restrictions are passed via server configuration + servers.push(AcpServerInfo { + pid, + port, + transport: transport.to_string(), + uptime: get_process_uptime(pid), + }); + } + } + } } + } - if !self.deny_tools.is_empty() { - eprintln!("Tool blacklist: {:?}", self.deny_tools); - // Note: Tool restrictions are passed via server configuration + // On Windows, use tasklist + #[cfg(windows)] + { + if let Ok(output) = Command::new("tasklist") + .args(["/FI", "IMAGENAME eq cortex.exe", "/FO", "CSV", "/NH"]) + .output() + { + if output.status.success() { + let output_str = String::from_utf8_lossy(&output.stdout); + for line in output_str.lines() { + // Parse CSV: "cortex.exe","12345","Console","1","12,345 K" + let parts: Vec<&str> = line.split(',').collect(); + if parts.len() >= 2 { + if let Ok(pid) = parts[1].trim_matches('"').parse::() { + servers.push(AcpServerInfo { + pid, + port: None, + transport: "unknown".to_string(), + uptime: None, + }); + } + } + } + } } + } + + let status = AcpStatus { + running: !servers.is_empty(), + servers, + }; + + if args.json { + println!("{}", serde_json::to_string_pretty(&status)?); + } else { + println!("ACP Server Status"); + println!("{}", "=".repeat(40)); - // Decide transport mode - if self.stdio || self.port == 0 { - // Use stdio transport - self.run_stdio_server(config).await + if status.servers.is_empty() { + println!("No ACP servers currently running."); + println!(); + println!("To start an ACP server:"); + println!(" cortex acp # stdio transport"); + println!(" cortex acp --port 8080 # HTTP transport on port 8080"); } else { - // Use HTTP transport - self.run_http_server(config).await + println!("Running ACP servers: {}", status.servers.len()); + println!(); + + for (i, server) in status.servers.iter().enumerate() { + println!("Server #{}", i + 1); + println!(" PID: {}", server.pid); + println!(" Transport: {}", server.transport); + if let Some(port) = server.port { + println!(" Port: {}", port); + } + if let Some(ref uptime) = server.uptime { + println!(" Uptime: {}", uptime); + } + println!(); + } } } - /// Run ACP server with stdio transport. - async fn run_stdio_server(&self, config: cortex_engine::Config) -> Result<()> { - eprintln!("Starting ACP server on stdio transport..."); + Ok(()) +} - let server = cortex_engine::acp::AcpServer::new(config); - server.run_stdio().await +/// Get the listening port for a process (Unix only). +#[cfg(unix)] +fn get_process_listening_port(pid: u32) -> Option { + use std::process::Command; + + // Use lsof to find listening ports + if let Ok(output) = Command::new("lsof") + .args(["-i", "-P", "-n", "-p", &pid.to_string()]) + .output() + { + if output.status.success() { + let output_str = String::from_utf8_lossy(&output.stdout); + for line in output_str.lines() { + if line.contains("LISTEN") { + // Parse port from line like: cortex 12345 user 3u IPv4 ... TCP *:8080 (LISTEN) + if let Some(addr_part) = line.split_whitespace().nth(8) { + if let Some(port_str) = addr_part.rsplit(':').next() { + if let Ok(port) = port_str.parse::() { + return Some(port); + } + } + } + } + } + } } + None +} - /// Run ACP server with HTTP transport. - async fn run_http_server(&self, config: cortex_engine::Config) -> Result<()> { - let addr: SocketAddr = format!("{}:{}", self.host, self.port).parse()?; +#[cfg(windows)] +fn get_process_listening_port(_pid: u32) -> Option { + // Windows implementation would use netstat + None +} - eprintln!("Starting ACP server on http://{}", addr); +/// Get process uptime (Unix only). +#[cfg(unix)] +fn get_process_uptime(pid: u32) -> Option { + use std::process::Command; - let server = cortex_engine::acp::AcpServer::new(config); - server.run_http(addr).await + if let Ok(output) = Command::new("ps") + .args(["-o", "etime=", "-p", &pid.to_string()]) + .output() + { + if output.status.success() { + let etime = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !etime.is_empty() { + return Some(etime); + } + } } + None +} + +#[cfg(windows)] +fn get_process_uptime(_pid: u32) -> Option { + None } diff --git a/cortex-cli/src/debug_cmd.rs b/cortex-cli/src/debug_cmd.rs index e95934ad..91f97228 100644 --- a/cortex-cli/src/debug_cmd.rs +++ b/cortex-cli/src/debug_cmd.rs @@ -192,7 +192,7 @@ async fn run_config(args: ConfigArgs) -> Result<()> { &output.resolved.provider }; println!(" Provider: {}", provider_desc); - println!(" CWD: {}", output.resolved.cwd.display()); + println!(" Working Dir: {}", output.resolved.cwd.display()); println!(" Cortex Home: {}", output.resolved.cortex_home.display()); println!(); diff --git a/cortex-cli/src/main.rs b/cortex-cli/src/main.rs index 23bad35b..0dd5cae5 100644 --- a/cortex-cli/src/main.rs +++ b/cortex-cli/src/main.rs @@ -529,6 +529,11 @@ enum Commands { /// Lock/protect sessions from deletion #[command(visible_alias = "protect")] Lock(LockCli), + + /// Show version information (supports JSON output) + #[command(display_order = 100)] + #[command(next_help_heading = CAT_UTILITIES)] + Version(VersionCommand), } // Note: ExecCommand has been replaced by the comprehensive ExecCli from exec_cmd module. @@ -781,6 +786,23 @@ enum SandboxCommand { /// Run a command under Windows restricted token (Windows only) Windows(WindowsCommand), + + /// Test sandbox functionality on this system. + /// Verifies that the sandbox is working correctly by creating a test + /// sandbox, attempting restricted operations, and reporting results. + Test(SandboxTestArgs), +} + +/// Arguments for the sandbox test command. +#[derive(Args)] +struct SandboxTestArgs { + /// Enable verbose output with detailed test results. + #[arg(long = "verbose", short = 'v')] + verbose: bool, + + /// Output test results in JSON format. + #[arg(long = "json")] + json: bool, } /// Features command. @@ -797,6 +819,19 @@ enum FeaturesSubcommand { List, } +/// Version command - show version information with optional JSON output. +#[derive(Args)] +struct VersionCommand { + /// Output version information in JSON format. + /// Includes version, build info, commit hash, and platform details. + #[arg(long)] + json: bool, + + /// Show verbose version information including all build metadata. + #[arg(long = "verbose", short = 'v')] + verbose: bool, +} + /// Serve command - runs HTTP API server. #[derive(Args)] struct ServeCommand { @@ -1200,6 +1235,7 @@ async fn main() -> Result<()> { SandboxCommand::Windows(cmd) => { cortex_cli::debug_sandbox::run_command_under_windows(cmd, None).await } + SandboxCommand::Test(args) => run_sandbox_test(args).await, }, Some(Commands::Resume(resume_cli)) => run_resume(resume_cli).await, Some(Commands::Sessions(sessions_cli)) => { @@ -1237,6 +1273,7 @@ async fn main() -> Result<()> { Some(Commands::Plugin(plugin_cli)) => plugin_cli.run().await, Some(Commands::Feedback(feedback_cli)) => feedback_cli.run().await, Some(Commands::Lock(lock_cli)) => lock_cli.run().await, + Some(Commands::Version(version_cmd)) => run_version(version_cmd).await, } } @@ -2214,7 +2251,7 @@ async fn show_config(config_cli: ConfigCommand) -> Result<()> { println!("Cortex Configuration:"); println!(" Model: {}", config.model); println!(" Provider: {}", config.model_provider_id); - println!(" CWD: {}", config.cwd.display()); + println!(" Working Directory: {}", config.cwd.display()); println!(" Cortex Home: {}", config.cortex_home.display()); // Also show TUI-specific settings (last selected model/provider) @@ -2950,3 +2987,284 @@ async fn check_for_updates_background() { } } } + +/// Run sandbox test to verify sandbox functionality. +async fn run_sandbox_test(args: SandboxTestArgs) -> Result<()> { + use serde::Serialize; + use std::process::Command; + + #[derive(Serialize)] + struct SandboxTestResult { + platform: String, + sandbox_available: bool, + sandbox_type: String, + tests: Vec, + overall_status: String, + } + + #[derive(Serialize)] + struct TestResult { + name: String, + passed: bool, + message: String, + } + + let mut tests = Vec::new(); + let platform = std::env::consts::OS; + let mut sandbox_available = false; + let mut sandbox_type = "none".to_string(); + + // Platform-specific sandbox testing + #[cfg(target_os = "linux")] + { + // Check if Landlock is available + sandbox_type = "landlock".to_string(); + + // Test 1: Check kernel support + let kernel_support = std::fs::read_to_string("/proc/sys/kernel/osrelease") + .map(|v| { + let parts: Vec<&str> = v.trim().split('.').collect(); + if parts.len() >= 2 { + let major: u32 = parts[0].parse().unwrap_or(0); + let minor: u32 = parts[1].parse().unwrap_or(0); + // Landlock requires Linux 5.13+ + major > 5 || (major == 5 && minor >= 13) + } else { + false + } + }) + .unwrap_or(false); + + tests.push(TestResult { + name: "Kernel version check (5.13+)".to_string(), + passed: kernel_support, + message: if kernel_support { + "Kernel supports Landlock".to_string() + } else { + "Kernel may not support Landlock (requires 5.13+)".to_string() + }, + }); + + // Test 2: Check Landlock filesystem support + let landlock_fs = std::path::Path::new("/sys/kernel/security/landlock").exists(); + tests.push(TestResult { + name: "Landlock filesystem".to_string(), + passed: landlock_fs, + message: if landlock_fs { + "Landlock security module is loaded".to_string() + } else { + "Landlock security module not available".to_string() + }, + }); + + sandbox_available = kernel_support && landlock_fs; + } + + #[cfg(target_os = "macos")] + { + sandbox_type = "seatbelt".to_string(); + + // Test 1: Check sandbox-exec command availability + let sandbox_exec = Command::new("which") + .arg("sandbox-exec") + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + + tests.push(TestResult { + name: "sandbox-exec availability".to_string(), + passed: sandbox_exec, + message: if sandbox_exec { + "sandbox-exec command is available".to_string() + } else { + "sandbox-exec command not found".to_string() + }, + }); + + // Test 2: Check if SIP allows sandboxing + let sip_check = Command::new("csrutil") + .arg("status") + .output() + .map(|o| { + let output = String::from_utf8_lossy(&o.stdout); + !output.contains("disabled") + }) + .unwrap_or(true); // Assume enabled if check fails + + tests.push(TestResult { + name: "System Integrity Protection".to_string(), + passed: sip_check, + message: if sip_check { + "SIP is enabled (sandboxing supported)".to_string() + } else { + "SIP may be disabled (sandboxing may be limited)".to_string() + }, + }); + + sandbox_available = sandbox_exec; + } + + #[cfg(target_os = "windows")] + { + sandbox_type = "restricted_token".to_string(); + + // Test 1: Check for Windows sandbox support (Windows 10/11) + let win_version = Command::new("cmd") + .args(["/C", "ver"]) + .output() + .map(|o| { + let output = String::from_utf8_lossy(&o.stdout); + output.contains("10.") || output.contains("11.") + }) + .unwrap_or(false); + + tests.push(TestResult { + name: "Windows version check".to_string(), + passed: win_version, + message: if win_version { + "Windows 10/11 detected (restricted tokens supported)".to_string() + } else { + "Windows version may not fully support sandboxing".to_string() + }, + }); + + sandbox_available = win_version; + } + + // Test common functionality + tests.push(TestResult { + name: "Temp directory access".to_string(), + passed: std::env::temp_dir().exists(), + message: "Temporary directory is accessible".to_string(), + }); + + let all_passed = tests.iter().all(|t| t.passed); + let overall_status = if all_passed && sandbox_available { + "PASS" + } else if sandbox_available { + "PARTIAL" + } else { + "UNAVAILABLE" + }; + + let result = SandboxTestResult { + platform: platform.to_string(), + sandbox_available, + sandbox_type: sandbox_type.clone(), + tests, + overall_status: overall_status.to_string(), + }; + + if args.json { + println!("{}", serde_json::to_string_pretty(&result)?); + } else { + println!("Sandbox Test Results"); + println!("{}", "=".repeat(50)); + println!(); + println!("Platform: {}", result.platform); + println!( + "Sandbox: {} ({})", + result.sandbox_type, + if result.sandbox_available { + "available" + } else { + "not available" + } + ); + println!(); + println!("Tests:"); + for test in &result.tests { + let status = if test.passed { "PASS" } else { "FAIL" }; + let color = if test.passed { "\x1b[32m" } else { "\x1b[31m" }; + println!( + " {} [{}{}{}] {}", + if test.passed { "✓" } else { "✗" }, + color, + status, + "\x1b[0m", + test.name + ); + if args.verbose { + println!(" {}", test.message); + } + } + println!(); + println!("Overall: {}", overall_status); + + if !result.sandbox_available { + println!(); + println!("Note: Sandbox is not available on this system."); + println!("Commands will run without sandboxing restrictions."); + } + } + + Ok(()) +} + +/// Show version information with optional JSON output. +async fn run_version(args: VersionCommand) -> Result<()> { + use serde::Serialize; + + const VERSION: &str = env!("CARGO_PKG_VERSION"); + const GIT_HASH: &str = option_env!("CORTEX_GIT_HASH").unwrap_or("unknown"); + const BUILD_DATE: &str = option_env!("CORTEX_BUILD_DATE").unwrap_or("unknown"); + + #[derive(Serialize)] + struct VersionInfo { + version: String, + #[serde(skip_serializing_if = "Option::is_none")] + build_date: Option, + #[serde(skip_serializing_if = "Option::is_none")] + commit: Option, + platform: PlatformInfo, + } + + #[derive(Serialize)] + struct PlatformInfo { + os: String, + arch: String, + #[serde(skip_serializing_if = "Option::is_none")] + family: Option, + } + + let version_info = VersionInfo { + version: VERSION.to_string(), + build_date: if BUILD_DATE != "unknown" { + Some(BUILD_DATE.to_string()) + } else { + None + }, + commit: if GIT_HASH != "unknown" { + Some(GIT_HASH.to_string()) + } else { + None + }, + platform: PlatformInfo { + os: std::env::consts::OS.to_string(), + arch: std::env::consts::ARCH.to_string(), + family: Some(std::env::consts::FAMILY.to_string()), + }, + }; + + if args.json { + println!("{}", serde_json::to_string_pretty(&version_info)?); + } else { + println!("cortex {}", VERSION); + + if args.verbose { + if GIT_HASH != "unknown" { + println!(" Commit: {}", GIT_HASH); + } + if BUILD_DATE != "unknown" { + println!(" Build Date: {}", BUILD_DATE); + } + println!( + " Platform: {}/{}", + std::env::consts::OS, + std::env::consts::ARCH + ); + } + } + + Ok(()) +} diff --git a/cortex-cli/src/pr_cmd.rs b/cortex-cli/src/pr_cmd.rs index 1d8dcf82..05c7aec5 100644 --- a/cortex-cli/src/pr_cmd.rs +++ b/cortex-cli/src/pr_cmd.rs @@ -1,13 +1,14 @@ -//! Pull Request checkout command. +//! Pull Request commands. //! //! Provides commands for working with GitHub pull requests: //! - `cortex pr ` - Checkout a PR branch locally +//! - `cortex pr list` - List pull requests in the repository //! //! SECURITY: All git command arguments are validated and passed as separate //! arguments to prevent shell injection attacks. use anyhow::{Context, Result, bail}; -use clap::Parser; +use clap::{Parser, Subcommand}; use std::path::PathBuf; use std::process::Command; @@ -56,8 +57,11 @@ fn validate_refspec(refspec: &str) -> Result<()> { /// Pull Request CLI. #[derive(Debug, Parser)] pub struct PrCli { - /// PR number to checkout. - pub number: u64, + #[command(subcommand)] + pub command: Option, + + /// PR number to checkout (shorthand for `pr checkout `). + pub number: Option, /// Path to the repository root (defaults to current directory). #[arg(short, long)] @@ -74,36 +78,228 @@ pub struct PrCli { #[arg(short = 'F', long)] pub force: bool, - /// Show PR details without checking out. + /// GitHub token for API access (for private repos). #[arg(long)] - pub info: bool, + pub token: Option, +} - /// Show PR diff without checking out. - #[arg(long)] - pub diff: bool, +/// PR subcommands. +#[derive(Debug, Subcommand)] +pub enum PrSubcommand { + /// List pull requests in the current repository. + #[command(visible_alias = "ls")] + List(PrListArgs), - /// Show PR comments. - #[arg(long)] - pub comments: bool, + /// Checkout a pull request branch. + Checkout(PrCheckoutArgs), +} + +/// Arguments for listing PRs. +#[derive(Debug, Parser)] +pub struct PrListArgs { + /// Filter PRs by state: open, closed, or all. + #[arg(long, short = 's', default_value = "open")] + pub state: String, + + /// Filter PRs by author username. + #[arg(long, short = 'a')] + pub author: Option, + + /// Filter PRs by label. + #[arg(long, short = 'l')] + pub label: Option, + + /// Sort PRs by: created, updated, popularity, long-running. + #[arg(long, default_value = "created")] + pub sort: String, + + /// Maximum number of PRs to show. + #[arg(long, short = 'n', default_value = "30")] + pub limit: u32, - /// Apply AI-suggested changes to working tree. + /// Output in JSON format. #[arg(long)] - pub apply: bool, + pub json: bool, + + /// Path to the repository root (defaults to current directory). + #[arg(short, long)] + pub path: Option, /// GitHub token for API access (for private repos). #[arg(long)] pub token: Option, } +/// Arguments for checking out a PR. +#[derive(Debug, Parser)] +pub struct PrCheckoutArgs { + /// PR number to checkout. + pub number: u64, + + /// Path to the repository root. + #[arg(short, long)] + pub path: Option, + + /// Custom local branch name. + #[arg(short, long)] + pub branch: Option, + + /// Force checkout. + #[arg(short = 'F', long)] + pub force: bool, + + /// GitHub token for API access. + #[arg(long)] + pub token: Option, +} + impl PrCli { /// Run the PR command. pub async fn run(self) -> Result<()> { - run_pr_checkout(self).await + match self.command { + Some(PrSubcommand::List(args)) => run_pr_list(args).await, + Some(PrSubcommand::Checkout(args)) => run_pr_checkout_direct(args).await, + None => { + // Handle legacy usage: `cortex pr ` + if let Some(number) = self.number { + let args = PrCheckoutArgs { + number, + path: self.path, + branch: self.branch, + force: self.force, + token: self.token, + }; + run_pr_checkout_direct(args).await + } else { + bail!( + "Usage: cortex pr or cortex pr list\n\nRun 'cortex pr --help' for more information." + ) + } + } + } + } +} + +/// List pull requests in the repository. +async fn run_pr_list(args: PrListArgs) -> Result<()> { + use cortex_engine::github::GitHubClient; + use serde::Serialize; + + #[derive(Serialize)] + struct PrSummary { + number: u64, + title: String, + author: String, + state: String, + draft: bool, + created_at: String, + updated_at: String, + labels: Vec, + } + + let repo_path = args.path.unwrap_or_else(|| PathBuf::from(".")); + + // Change to repo directory + std::env::set_current_dir(&repo_path) + .with_context(|| format!("Failed to change to directory: {}", repo_path.display()))?; + + // Check if we're in a git repo + if !repo_path.join(".git").exists() { + bail!("Not a git repository. Run this command from a git repository root."); + } + + // Get the remote URL to determine owner/repo + let remote_url = get_git_remote_url()?; + let (owner, repo) = parse_github_url(&remote_url) + .with_context(|| format!("Failed to parse GitHub URL: {}", remote_url))?; + + let repository = format!("{}/{}", owner, repo); + + // Create GitHub client + let client = if let Some(ref token) = args.token { + GitHubClient::new(token, &repository)? + } else { + GitHubClient::anonymous(&repository)? + }; + + // List PRs + let prs = client + .list_pull_requests(Some(&args.state), args.limit) + .await?; + + // Filter by author if specified + let prs: Vec<_> = if let Some(ref author) = args.author { + prs.into_iter() + .filter(|pr| pr.author.to_lowercase() == author.to_lowercase()) + .collect() + } else { + prs + }; + + // Filter by label if specified + let prs: Vec<_> = if let Some(ref label) = args.label { + prs.into_iter() + .filter(|pr| { + pr.labels + .iter() + .any(|l| l.to_lowercase() == label.to_lowercase()) + }) + .collect() + } else { + prs + }; + + if args.json { + let summaries: Vec = prs + .iter() + .map(|pr| PrSummary { + number: pr.number, + title: pr.title.clone(), + author: pr.author.clone(), + state: pr.state.clone(), + draft: pr.draft, + created_at: pr.created_at.clone(), + updated_at: pr.updated_at.clone(), + labels: pr.labels.clone(), + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&summaries)?); + } else { + println!("Pull Requests for {}", repository); + println!("{}", "=".repeat(60)); + println!(); + + if prs.is_empty() { + println!("No {} pull requests found.", args.state); + } else { + for pr in &prs { + let draft_indicator = if pr.draft { " [draft]" } else { "" }; + let state_color = match pr.state.as_str() { + "open" => "\x1b[32m", // Green + "closed" => "\x1b[31m", // Red + "merged" => "\x1b[35m", // Magenta + _ => "\x1b[0m", + }; + println!( + "#{:<5} {}{:<8}{}\x1b[0m @{:<15}{}", + pr.number, + state_color, + pr.state, + draft_indicator, + pr.author, + &pr.title[..pr.title.len().min(50)] + ); + } + println!(); + println!("Total: {} PRs", prs.len()); + } } + + Ok(()) } -/// Checkout a pull request branch. -async fn run_pr_checkout(args: PrCli) -> Result<()> { +/// Checkout a pull request branch directly. +async fn run_pr_checkout_direct(args: PrCheckoutArgs) -> Result<()> { use cortex_engine::github::GitHubClient; let repo_path = args.path.unwrap_or_else(|| PathBuf::from(".")); @@ -176,128 +372,6 @@ async fn run_pr_checkout(args: PrCli) -> Result<()> { } println!(); - // If --info flag, just show info and exit - if args.info { - println!("URL: https://github.com/{}/pull/{}", repository, pr_number); - return Ok(()); - } - - // If --diff flag, show diff without checkout - if args.diff { - println!("Fetching PR diff..."); - println!(); - - // Fetch the PR branch to show diff - let branch_name = format!("pr-{}", pr_number); - let refspec = format!("pull/{}/head:{}", pr_number, branch_name); - - let fetch_output = Command::new("git") - .args(["fetch", "origin", &refspec]) - .output() - .context("Failed to fetch PR")?; - - if !fetch_output.status.success() { - let stderr = String::from_utf8_lossy(&fetch_output.stderr); - bail!("Failed to fetch PR: {}", stderr); - } - - let diff_output = Command::new("git") - .args([ - "diff", - &format!("{}...{}", pr_info.base_branch, branch_name), - ]) - .output() - .context("Failed to run git diff")?; - - if diff_output.status.success() { - let diff_content = String::from_utf8_lossy(&diff_output.stdout); - if diff_content.is_empty() { - println!("No changes in this PR."); - } else { - println!("{}", diff_content); - } - } else { - bail!( - "Failed to get diff: {}", - String::from_utf8_lossy(&diff_output.stderr) - ); - } - return Ok(()); - } - - // If --comments flag, show PR comments - if args.comments { - println!("Fetching PR comments..."); - println!(); - - // Use gh CLI to get comments if available, otherwise show message - let gh_output = Command::new("gh") - .args([ - "pr", - "view", - &pr_number.to_string(), - "--repo", - &repository, - "--comments", - ]) - .output(); - - match gh_output { - Ok(output) if output.status.success() => { - let comments = String::from_utf8_lossy(&output.stdout); - if comments.trim().is_empty() { - println!("No comments on this PR."); - } else { - println!("{}", comments); - } - } - _ => { - println!("Note: Install GitHub CLI (gh) for full comment viewing."); - println!(); - println!("Alternative: View comments at:"); - println!(" https://github.com/{}/pull/{}", repository, pr_number); - } - } - return Ok(()); - } - - // If --apply flag, apply AI suggestions - if args.apply { - println!("Fetching AI suggestions for PR #{}...", pr_number); - println!(); - - // Check for changed files to look for suggestions - match client.list_pull_request_files(pr_number).await { - Ok(files) => { - if files.is_empty() { - println!("No files changed in this PR."); - } else { - println!("Files changed in PR:"); - for file in &files { - println!( - " {} (+{} -{}) {}", - file.status, file.additions, file.deletions, file.filename - ); - } - println!(); - println!("Note: Automatic suggestion application is not yet implemented."); - println!("Please review suggestions manually at:"); - println!( - " https://github.com/{}/pull/{}/files", - repository, pr_number - ); - } - } - Err(e) => { - eprintln!("Warning: Could not fetch file list: {}", e); - println!(); - println!("View suggestions at:"); - println!(" https://github.com/{}/pull/{}", repository, pr_number); - } - } - return Ok(()); - } - // Check for uncommitted changes if !args.force { let status_output = Command::new("git") diff --git a/cortex-cli/src/run_cmd.rs b/cortex-cli/src/run_cmd.rs index f166233d..aaa0a090 100644 --- a/cortex-cli/src/run_cmd.rs +++ b/cortex-cli/src/run_cmd.rs @@ -147,9 +147,19 @@ pub struct RunCli { pub seed: Option, /// Send a desktop notification when the task completes. + /// Equivalent to --notify-on-success --notify-on-error. #[arg(short = 'n', long = "notification")] pub notification: bool, + /// Send a desktop notification only on successful completion. + #[arg(long = "notify-on-success")] + pub notify_on_success: bool, + + /// Send a desktop notification only when the task fails. + /// Useful for background tasks where you only want to be alerted on errors. + #[arg(long = "notify-on-error")] + pub notify_on_error: bool, + /// Stream output as it arrives (default behavior). /// Use --no-stream to buffer and wait for the complete response. #[arg(long = "stream", default_value_t = true, action = clap::ArgAction::SetTrue, overrides_with = "no_stream")] @@ -1093,7 +1103,18 @@ impl RunCli { final_message.push_str(&delta.delta); } EventMsg::ExecCommandBegin(cmd_begin) => { - if !is_json && is_terminal && !self.quiet && !self.no_progress { + // Hide progress indicators when: + // - JSON output mode + // - Not a terminal + // - Quiet mode + // - Progress disabled + // - Streaming disabled (non-streaming mode implies waiting for complete response) + if !is_json + && is_terminal + && !self.quiet + && !self.no_progress + && streaming_enabled + { let display = get_tool_display("bash"); let title = cmd_begin.command.join(" "); println!( @@ -1123,7 +1144,13 @@ impl RunCli { } } EventMsg::McpToolCallBegin(mcp_begin) => { - if !is_json && is_terminal && !self.quiet && !self.no_progress { + // Hide progress indicators in non-streaming mode + if !is_json + && is_terminal + && !self.quiet + && !self.no_progress + && streaming_enabled + { let display = get_tool_display(&mcp_begin.invocation.tool); println!( "{}|{} {:<7} {}{}", @@ -1284,8 +1311,16 @@ impl RunCli { } // Send desktop notification if requested - if self.notification { - send_notification(&session_id, !error_occurred)?; + // --notification implies both success and error notifications + // --notify-on-success sends notification only on success + // --notify-on-error sends notification only on error + let should_notify_success = self.notification || self.notify_on_success; + let should_notify_error = self.notification || self.notify_on_error; + + if error_occurred && should_notify_error { + send_notification(&session_id, false)?; + } else if !error_occurred && should_notify_success { + send_notification(&session_id, true)?; } // Share session if requested diff --git a/cortex-engine/examples/test_api.rs b/cortex-engine/examples/test_api.rs index 2c0872f5..3fa4564d 100644 --- a/cortex-engine/examples/test_api.rs +++ b/cortex-engine/examples/test_api.rs @@ -1,9 +1,12 @@ use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, USER_AGENT}; +// Use the actual version from the crate +const APP_USER_AGENT: &str = concat!("cortex-cli/", env!("CARGO_PKG_VERSION")); + #[tokio::main] async fn main() { let client = reqwest::Client::builder() - .user_agent("cortex-cli/0.1.0") + .user_agent(APP_USER_AGENT) .timeout(std::time::Duration::from_secs(300)) .tcp_nodelay(true) .build() @@ -37,7 +40,7 @@ async fn main() { .post("https://api.cortex.foundation/v1/responses") .header(CONTENT_TYPE, "application/json") .header(ACCEPT, "text/event-stream") - .header(USER_AGENT, "cortex-cli/0.1.0") + .header(USER_AGENT, APP_USER_AGENT) .header( AUTHORIZATION, "Bearer ctx_live_sk_x060S3S0g0N0P2o3a1f370Y1y073t2P2q170K093D3b3y160s2D0k2a3I1l2H0o2", diff --git a/cortex-engine/src/config/loader.rs b/cortex-engine/src/config/loader.rs index cf0cb67b..8559121f 100644 --- a/cortex-engine/src/config/loader.rs +++ b/cortex-engine/src/config/loader.rs @@ -418,17 +418,79 @@ pub fn strip_json_comments(input: &str) -> String { result } +/// Apply environment variable substitution to config content. +/// +/// Supports the following syntax: +/// - `{env:VAR_NAME}` - Substitutes with environment variable value +/// - `{env:VAR_NAME:default}` - Substitutes with environment variable or default if not set +/// - `$VAR` or `${VAR}` - Shell-style substitution (converted to {env:VAR} format) +/// +/// This allows flexible deployment configurations where settings can vary per environment. +fn apply_env_substitution(content: &str) -> String { + use cortex_common::ConfigSubstitution; + + let substitution = ConfigSubstitution::new(); + + // First, convert shell-style $VAR and ${VAR} to {env:VAR} format for consistency + let converted = convert_shell_vars(content); + + // Apply the substitution, returning original content if any errors occur + match substitution.substitute(&converted) { + Ok(result) => result, + Err(e) => { + debug!(error = %e, "Environment variable substitution failed, using original content"); + content.to_string() + } + } +} + +/// Convert shell-style environment variable references to {env:VAR} format. +/// +/// Handles: +/// - `$VAR` -> `{env:VAR}` +/// - `${VAR}` -> `{env:VAR}` +/// - `${VAR:-default}` -> `{env:VAR:default}` +/// +/// Preserves escaped variables like `\$VAR` and variables inside quotes that +/// are already in {env:VAR} format. +fn convert_shell_vars(content: &str) -> String { + use regex::Regex; + + // Match ${VAR:-default} pattern (bash default syntax) + let re_default = Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*):-([^}]*)\}").expect("invalid regex"); + let result = re_default.replace_all(content, "{env:$1:$2}"); + + // Match ${VAR} pattern + let re_braces = Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").expect("invalid regex"); + let result = re_braces.replace_all(&result, "{env:$1}"); + + // Match $VAR pattern (not followed by { which would be handled above) + // Be careful not to match things like $1, $?, etc. - only valid identifiers + let re_simple = Regex::new(r"\$([A-Za-z_][A-Za-z0-9_]*)").expect("invalid regex"); + let result = re_simple.replace_all(&result, "{env:$1}"); + + result.to_string() +} + /// Parse configuration content based on format. +/// +/// Supports environment variable expansion in config values: +/// - `{env:VAR_NAME}` - Substitutes with environment variable value +/// - `{env:VAR_NAME:default}` - Substitutes with env var or default if not set +/// - `$VAR` or `${VAR}` - Shell-style substitution pub fn parse_config_content(content: &str, format: ConfigFormat) -> std::io::Result { + // Apply environment variable substitution before parsing + let substituted = apply_env_substitution(content); + match format { - ConfigFormat::Toml => toml::from_str(content).map_err(|e| { + ConfigFormat::Toml => toml::from_str(&substituted).map_err(|e| { std::io::Error::new( std::io::ErrorKind::InvalidData, - format_toml_error(&e, content), + format_toml_error(&e, &substituted), ) }), ConfigFormat::JsonC => { - let stripped = strip_json_comments(content); + let stripped = strip_json_comments(&substituted); serde_json::from_str(&stripped).map_err(|e| { std::io::Error::new( std::io::ErrorKind::InvalidData, diff --git a/cortex-engine/src/config/project_config.rs b/cortex-engine/src/config/project_config.rs index ee2a860e..a159c732 100644 --- a/cortex-engine/src/config/project_config.rs +++ b/cortex-engine/src/config/project_config.rs @@ -197,6 +197,13 @@ pub fn merge_configs(global: ConfigToml, project: Option) -> ConfigT // Model aliases: additive merge (project overrides global for same alias) model_aliases: merge_hash_maps(global.model_aliases, project.model_aliases), + + // Session security: project overrides global + session_security: if project.session_security.encrypt_at_rest { + project.session_security + } else { + global.session_security + }, } } diff --git a/cortex-engine/src/config/types.rs b/cortex-engine/src/config/types.rs index 1cde8d4f..bfd5a548 100644 --- a/cortex-engine/src/config/types.rs +++ b/cortex-engine/src/config/types.rs @@ -114,6 +114,9 @@ pub struct ConfigToml { /// Example: `fast = "gpt-4-turbo"`, `coding = "claude-sonnet-4"` #[serde(default)] pub model_aliases: HashMap, + /// Session security settings. + #[serde(default)] + pub session_security: SessionSecurityConfig, } /// Profile configuration - named presets. @@ -219,6 +222,28 @@ pub struct NotificationsConfig { pub enabled: bool, } +/// Session security configuration. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct SessionSecurityConfig { + /// Enable encryption for session data at rest. + /// When enabled, session files are encrypted using a key derived from + /// the system keyring or a user-provided password. + #[serde(default)] + pub encrypt_at_rest: bool, + + /// Key derivation source for session encryption. + /// Options: + /// - "keyring" (default): Derive key from system keyring + /// - "password": Prompt for password on first access + /// - "env": Use CORTEX_SESSION_KEY environment variable + #[serde(default = "default_encryption_key_source")] + pub encryption_key_source: String, +} + +fn default_encryption_key_source() -> String { + "keyring".to_string() +} + /// Model provider information. #[derive(Debug, Clone)] pub struct ModelProviderInfo {