Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/cortex-cli/src/agent_cmd/handlers/list.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! Handler for the `agent list` command.

use anyhow::Result;
use anyhow::{Result, bail};

use crate::agent_cmd::cli::ListArgs;
use crate::agent_cmd::loader::load_all_agents;
Expand All @@ -9,6 +9,13 @@ use crate::agent_cmd::utils::matches_pattern;

/// List agents command.
pub async fn run_list(args: ListArgs) -> Result<()> {
// Validate mutually exclusive flags
if args.primary && args.subagents {
bail!(
"Cannot specify both --primary and --subagents. Choose one filter or use neither for all agents."
);
}

// Handle --remote flag
if args.remote {
println!("Remote agent registry:");
Expand Down
18 changes: 14 additions & 4 deletions src/cortex-cli/src/alias_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,10 +241,20 @@ async fn run_remove(args: AliasRemoveArgs) -> Result<()> {
async fn run_show(args: AliasShowArgs) -> Result<()> {
let config = load_aliases()?;

let alias = config
.aliases
.get(&args.name)
.ok_or_else(|| anyhow::anyhow!("Alias '{}' does not exist.", args.name))?;
let alias = match config.aliases.get(&args.name) {
Some(a) => a,
None => {
if args.json {
let error = serde_json::json!({
"error": format!("Alias '{}' does not exist", args.name)
});
println!("{}", serde_json::to_string_pretty(&error)?);
// Exit with error code but don't duplicate error message via bail!()
std::process::exit(1);
Comment on lines +247 to +253
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

using std::process::exit(1) here bypasses proper error handling and resource cleanup. The mcp_cmd/handlers.rs:133 uses bail!() after printing JSON, which is the correct pattern

Suggested change
if args.json {
let error = serde_json::json!({
"error": format!("Alias '{}' does not exist", args.name)
});
println!("{}", serde_json::to_string_pretty(&error)?);
// Exit with error code but don't duplicate error message via bail!()
std::process::exit(1);
if args.json {
let error = serde_json::json!({
"error": format!("Alias '{}' does not exist", args.name)
});
println!("{}", serde_json::to_string_pretty(&error)?);
}
bail!("Alias '{}' does not exist.", args.name);
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/cortex-cli/src/alias_cmd.rs
Line: 247:253

Comment:
using `std::process::exit(1)` here bypasses proper error handling and resource cleanup. The `mcp_cmd/handlers.rs:133` uses `bail!()` after printing JSON, which is the correct pattern

```suggestion
            if args.json {
                let error = serde_json::json!({
                    "error": format!("Alias '{}' does not exist", args.name)
                });
                println!("{}", serde_json::to_string_pretty(&error)?);
            }
            bail!("Alias '{}' does not exist.", args.name);
```

How can I resolve this? If you propose a fix, please make it concise.

}
bail!("Alias '{}' does not exist.", args.name);
}
};

if args.json {
println!("{}", serde_json::to_string_pretty(alias)?);
Expand Down
47 changes: 38 additions & 9 deletions src/cortex-cli/src/cli/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ pub async fn dispatch_command(cli: Cli) -> Result<()> {
Some(Commands::Login(login_cli)) => handle_login(login_cli).await,
Some(Commands::Logout(logout_cli)) => handle_logout(logout_cli).await,
Some(Commands::Whoami) => {
run_whoami().await;
run_whoami().await?;
Ok(())
}
Some(Commands::Mcp(mcp_cli)) => mcp_cli.run().await,
Expand Down Expand Up @@ -182,6 +182,23 @@ async fn run_init(init_cli: InitCommand) -> Result<()> {

/// Handle login command.
async fn handle_login(login_cli: LoginCommand) -> Result<()> {
// Validate mutually exclusive authentication methods
let auth_methods_count = [
login_cli.token.is_some(),
login_cli.use_sso,
login_cli.use_device_code,
login_cli.with_api_key,
]
.iter()
.filter(|&&x| x)
.count();

if auth_methods_count > 1 {
bail!(
"Cannot specify multiple authentication methods. Choose one: --token, --sso, --device-auth, or --with-api-key."
);
}

match login_cli.action {
Some(LoginSubcommand::Status) => run_login_status(login_cli.config_overrides).await,
None => {
Expand Down Expand Up @@ -512,7 +529,7 @@ fn install_completions(shell: Shell) -> Result<()> {
// ============================================================================

/// Show current logged-in user.
pub async fn run_whoami() {
pub async fn run_whoami() -> Result<()> {
use cortex_login::{AuthMode, load_auth_with_fallback, safe_format_key};

let cortex_home = dirs::home_dir()
Expand All @@ -527,7 +544,7 @@ pub async fn run_whoami() {
"Authenticated via CORTEX_AUTH_TOKEN: {}",
safe_format_key(&token)
);
return;
return Ok(());
}

if let Ok(token) = std::env::var("CORTEX_API_KEY")
Expand All @@ -537,7 +554,7 @@ pub async fn run_whoami() {
"Authenticated via CORTEX_API_KEY: {}",
safe_format_key(&token)
);
return;
return Ok(());
}

// Load stored credentials
Expand Down Expand Up @@ -565,9 +582,11 @@ pub async fn run_whoami() {
println!("Not logged in. Run 'cortex login' to authenticate.");
}
Err(e) => {
eprintln!("Error checking login status: {}", e);
return Err(anyhow::anyhow!("Error checking login status: {}", e));
}
}

Ok(())
}

/// Resume a previous session.
Expand All @@ -585,6 +604,16 @@ pub async fn run_resume(resume_cli: ResumeCommand) -> Result<()> {
let config = cortex_engine::Config::default();

let id_str = match (resume_cli.session_id, resume_cli.last, resume_cli.pick) {
// Support "last" as SESSION_ID as documented in help text (Issue #3646)
(Some(id), _, _) if id.to_lowercase() == "last" => {
let sessions = cortex_engine::list_sessions(&config.cortex_home)?;
if sessions.is_empty() {
print_info("No sessions found to resume.");
return Ok(());
}
print_info("Resuming most recent session...");
sessions[0].id.clone()
}
(Some(id), _, _) => id,
(None, true, _) => {
let sessions = cortex_engine::list_sessions(&config.cortex_home)?;
Expand Down Expand Up @@ -810,10 +839,10 @@ pub async fn show_config(config_cli: ConfigCommand) -> Result<()> {
if let Some(value) = config.get(&args.key) {
println!("{}", value);
} else {
println!("Key '{}' not found.", args.key);
bail!("Key '{}' not found in configuration", args.key);
}
} else {
println!("No configuration file found.");
bail!("No configuration file found");
}
}
Some(ConfigSubcommand::Set(args)) => {
Expand Down Expand Up @@ -841,10 +870,10 @@ pub async fn show_config(config_cli: ConfigCommand) -> Result<()> {
std::fs::write(&config_path, content)?;
print_success(&format!("Removed key: {}", args.key));
} else {
print_warning(&format!("Key '{}' not found.", args.key));
bail!("Key '{}' not found in configuration", args.key);
}
} else {
print_warning("No configuration file found.");
bail!("No configuration file found");
}
}
None => {
Expand Down
4 changes: 2 additions & 2 deletions src/cortex-cli/src/cli/styles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ pub const AFTER_HELP: &str = color_print::cstr!(
<dim>Agents</> ~/.cortex/agents/ (personal), .cortex/agents/ (project)

<cyan,bold>🔗 LEARN MORE</>
<blue,underline>https://docs.cortex.foundation</> Documentation
<blue,underline>https://github.com/CortexLM/cortex-cli</> Source & Issues"#
<blue,underline>https://docs.cortex.foundation</> Documentation
<blue,underline>https://github.com/CortexLM/cortex</> Source & Issues"#
);

/// Before-help section with ASCII art banner.
Expand Down
51 changes: 25 additions & 26 deletions src/cortex-cli/src/compact_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -407,33 +407,32 @@ async fn run_compact(args: CompactRunArgs) -> Result<()> {
}

if args.dry_run {
println!("Dry run mode - no changes will be made.");
println!();
let log_files_count = dir_stats(&logs_dir).0;
let session_files_count = dir_stats(&sessions_dir).0;
let history_files_count = dir_stats(&history_dir).0;
let orphaned_history_count = count_orphaned_history(&sessions_dir, &history_dir);

let status = CompactionStatus {
data_dir: data_dir.clone(),
logs_dir: logs_dir.clone(),
sessions_dir: sessions_dir.clone(),
history_dir: history_dir.clone(),
log_files_count: dir_stats(&logs_dir).0,
log_files_size: dir_stats(&logs_dir).1,
log_files_size_human: format_size(dir_stats(&logs_dir).1),
session_files_count: dir_stats(&sessions_dir).0,
history_files_count: dir_stats(&history_dir).0,
orphaned_history_count: count_orphaned_history(&sessions_dir, &history_dir),
total_data_size: 0,
total_data_size_human: String::new(),
lock_held: false,
};

println!("Would process:");
println!(" Log files: {}", status.log_files_count);
println!(" Session files: {}", status.session_files_count);
println!(" History files: {}", status.history_files_count);
println!(
" Orphaned files to clean: {}",
status.orphaned_history_count
);
if args.json {
let output = serde_json::json!({
"dry_run": true,
"would_process": {
"log_files": log_files_count,
"session_files": session_files_count,
"history_files": history_files_count,
"orphaned_files_to_clean": orphaned_history_count
},
"message": "Dry run completed - no changes made"
});
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
println!("Dry run mode - no changes will be made.");
println!();
println!("Would process:");
println!(" Log files: {}", log_files_count);
println!(" Session files: {}", session_files_count);
println!(" History files: {}", history_files_count);
println!(" Orphaned files to clean: {}", orphaned_history_count);
}
return Ok(());
}

Expand Down
47 changes: 39 additions & 8 deletions src/cortex-cli/src/dag_cmd/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ pub async fn run_create(args: DagCreateArgs) -> Result<()> {

/// Execute a DAG.
pub async fn run_execute(args: DagRunArgs) -> Result<()> {
// Validate --jobs argument (Issue #3716)
if args.max_concurrent == 0 {
bail!("--jobs must be at least 1. Use --jobs 1 for sequential execution.");
}

let spec = load_spec(&args.file)?;
let specs = convert_specs(&spec);

Expand All @@ -86,14 +91,33 @@ pub async fn run_execute(args: DagRunArgs) -> Result<()> {

if matches!(args.strategy, ExecutionStrategy::DryRun) {
// Dry run - just validate
dag.topological_sort()
let order = dag
.topological_sort()
.map_err(|e| anyhow::anyhow!("DAG validation failed: {}", e))?;
print_success(&format!(
"✓ DAG is valid ({} tasks in execution order)",
dag.len()
));
println!();
print_execution_order(&dag)?;

match args.format {
DagOutputFormat::Json => {
let task_names: Vec<String> = order
.iter()
.filter_map(|id| dag.get_task(*id).map(|t| t.name.clone()))
.collect();
let output = serde_json::json!({
"dry_run": true,
"valid": true,
"task_count": dag.len(),
"execution_order": task_names
});
println!("{}", serde_json::to_string_pretty(&output)?);
}
_ => {
print_success(&format!(
"✓ DAG is valid ({} tasks in execution order)",
dag.len()
));
println!();
print_execution_order(&dag)?;
}
}
return Ok(());
}

Expand Down Expand Up @@ -251,7 +275,14 @@ pub async fn run_list(args: DagListArgs) -> Result<()> {
let ids = store.list().await?;

if ids.is_empty() {
print_info("No DAGs found");
match args.format {
DagOutputFormat::Json => {
println!("[]");
}
_ => {
print_info("No DAGs found");
}
}
return Ok(());
}

Expand Down
32 changes: 24 additions & 8 deletions src/cortex-cli/src/dag_cmd/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ pub fn load_spec(path: &PathBuf) -> Result<DagSpecInput> {
serde_yaml::from_str(&content).context("Failed to parse YAML")?
};

// Check for duplicate task IDs (Issue #3815)
let mut seen_names = std::collections::HashSet::new();
for task in &spec.tasks {
if !seen_names.insert(&task.name) {
anyhow::bail!(
"Duplicate task ID '{}' found in DAG specification. Task IDs must be unique.",
task.name
);
}
}

Ok(spec)
}

Expand Down Expand Up @@ -67,8 +78,9 @@ pub fn convert_specs(input: &DagSpecInput) -> Vec<TaskSpec> {
pub fn print_dag_summary(dag: &TaskDag) {
println!("Tasks:");
for task in dag.all_tasks() {
let deps = dag
.get_dependencies(task.id.unwrap())
let deps = task
.id
.and_then(|id| dag.get_dependencies(id))
.map(|d| d.len())
.unwrap_or(0);
let dep_str = if deps > 0 {
Expand Down Expand Up @@ -119,7 +131,11 @@ pub fn print_execution_summary(stats: &DagExecutionStats, format: DagOutputForma
"skipped": stats.skipped_tasks,
"duration_secs": stats.total_duration.as_secs_f64()
});
println!("{}", serde_json::to_string_pretty(&output).unwrap());
println!(
"{}",
serde_json::to_string_pretty(&output)
.expect("JSON serialization should not fail for DagExecutionStats")
);
}
DagOutputFormat::Compact => {
println!(
Expand Down Expand Up @@ -170,7 +186,7 @@ pub fn print_ascii_graph(dag: &TaskDag, spec: &DagSpecInput) {
for task_id in tasks_at_depth {
if let Some(task) = dag.get_task(*task_id) {
let deps = dag.get_dependencies(*task_id);
let arrow = if deps.map(|d| !d.is_empty()).unwrap_or(false) {
let arrow = if deps.is_some_and(|d| !d.is_empty()) {
"└─► "
} else {
"● "
Expand All @@ -195,7 +211,7 @@ pub fn print_dot_graph(dag: &TaskDag, spec: &DagSpecInput) {
println!();

for task in dag.all_tasks() {
let task_id = task.id.unwrap();
let Some(task_id) = task.id else { continue };
let color = match task.status {
TaskStatus::Completed => "green",
TaskStatus::Failed => "red",
Expand All @@ -214,7 +230,7 @@ pub fn print_dot_graph(dag: &TaskDag, spec: &DagSpecInput) {
println!();

for task in dag.all_tasks() {
let task_id = task.id.unwrap();
let Some(task_id) = task.id else { continue };
if let Some(deps) = dag.get_dependencies(task_id) {
for dep_id in deps {
println!(" \"{}\" -> \"{}\";", dep_id.inner(), task_id.inner());
Expand All @@ -234,14 +250,14 @@ pub fn print_mermaid_graph(dag: &TaskDag, spec: &DagSpecInput) {
// Create task ID to safe name mapping
let mut id_to_name: HashMap<TaskId, String> = HashMap::new();
for task in dag.all_tasks() {
let task_id = task.id.unwrap();
let Some(task_id) = task.id else { continue };
let safe_name = task.name.replace([' ', '-'], "_");
id_to_name.insert(task_id, safe_name.clone());
println!(" {}[{}]", safe_name, task.name);
}

for task in dag.all_tasks() {
let task_id = task.id.unwrap();
let Some(task_id) = task.id else { continue };
if let Some(deps) = dag.get_dependencies(task_id) {
for dep_id in deps {
if let (Some(from), Some(to)) = (id_to_name.get(dep_id), id_to_name.get(&task_id)) {
Expand Down
Loading