Skip to content
Open
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
260 changes: 254 additions & 6 deletions cortex-cli/src/agent_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ pub enum AgentSubcommand {

/// Install an agent from the registry.
Install(InstallArgs),

/// Test and validate an agent configuration without running it (#2928).
Test(TestArgs),
}

/// Arguments for list command.
Expand All @@ -56,6 +59,12 @@ pub struct ListArgs {
#[arg(long)]
pub subagents: bool,

/// Filter by agent mode: primary, subagent, or all.
/// Can specify multiple modes separated by commas (e.g., --mode primary,subagent).
/// This is a unified alternative to --primary and --subagents flags.
#[arg(long, value_delimiter = ',')]
pub mode: Vec<String>,

/// Show all agents including hidden ones.
#[arg(long)]
pub all: bool,
Expand Down Expand Up @@ -165,6 +174,17 @@ pub struct InstallArgs {
pub registry: Option<String>,
}

/// Arguments for test command (#2928).
#[derive(Debug, Parser)]
pub struct TestArgs {
/// Name of the agent to test.
pub name: String,

/// Output as JSON.
#[arg(long)]
pub json: bool,
}

/// Agent operation mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
Expand Down Expand Up @@ -336,6 +356,7 @@ impl AgentCli {
AgentSubcommand::Edit(args) => run_edit(args).await,
AgentSubcommand::Remove(args) => run_remove(args).await,
AgentSubcommand::Install(args) => run_install(args).await,
AgentSubcommand::Test(args) => run_test(args).await,
}
}
}
Expand Down Expand Up @@ -900,6 +921,22 @@ async fn run_list(args: ListArgs) -> Result<()> {

let agents = load_all_agents()?;

// Parse --mode filter values into agent modes
let mode_filters: Vec<AgentMode> = args
.mode
.iter()
.filter_map(|m| m.parse::<AgentMode>().ok())
.collect();

// Determine effective mode filters from both --mode flag and legacy --primary/--subagents flags
let filter_primary =
args.primary || mode_filters.iter().any(|m| matches!(m, AgentMode::Primary));
let filter_subagents = args.subagents
|| mode_filters
.iter()
.any(|m| matches!(m, AgentMode::Subagent));
let filter_all_modes = mode_filters.iter().any(|m| matches!(m, AgentMode::All));

// Filter agents
let mut filtered: Vec<_> = agents
.iter()
Expand All @@ -908,12 +945,23 @@ async fn run_list(args: ListArgs) -> Result<()> {
if !args.all && a.hidden {
return false;
}
// Filter by mode
if args.primary && !matches!(a.mode, AgentMode::Primary | AgentMode::All) {
return false;
}
if args.subagents && !matches!(a.mode, AgentMode::Subagent | AgentMode::All) {
return false;
// Filter by mode (using unified logic for --mode flag and legacy --primary/--subagents)
// If filter_all_modes is set, show all modes
// If both filter_primary and filter_subagents are set, show all
// If neither is set and no mode filters, show all
let show_all_modes = filter_all_modes
|| (filter_primary && filter_subagents)
|| (!filter_primary && !filter_subagents && mode_filters.is_empty());
if !show_all_modes {
if filter_primary && !matches!(a.mode, AgentMode::Primary | AgentMode::All) {
return false;
}
if filter_subagents
&& !filter_primary
&& !matches!(a.mode, AgentMode::Subagent | AgentMode::All)
{
return false;
}
}
// Filter by pattern
if let Some(ref pattern) = args.filter {
Expand Down Expand Up @@ -1755,6 +1803,206 @@ async fn run_install(args: InstallArgs) -> Result<()> {
Ok(())
}

/// Test and validate an agent configuration without running it (#2928).
async fn run_test(args: TestArgs) -> Result<()> {
let agents = load_all_agents()?;

let agent = agents
.iter()
.find(|a| a.name == args.name)
.ok_or_else(|| anyhow::anyhow!("Agent '{}' not found", args.name))?;

// Validation result structure
let mut errors: Vec<String> = Vec::new();
let mut warnings: Vec<String> = Vec::new();

// Check for valid name
if agent.name.trim().is_empty() {
errors.push("Agent name is empty".to_string());
}

// Check for valid mode
// (mode is always valid if it was parsed, so we check consistency)
if agent.native && matches!(agent.mode, AgentMode::Subagent) {
warnings.push("Built-in agents as subagents cannot be modified".to_string());
}

// Check model reference if specified
if let Some(ref model) = agent.model {
if model.trim().is_empty() {
errors.push("Model name is specified but empty".to_string());
}
// Validate model format
if !model.contains('/')
&& !model
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' || c == ':')
{
warnings.push(format!(
"Model '{}' may not be in the expected format (provider/model or alias)",
model
));
}
}

// Check temperature if specified
if let Some(temp) = agent.temperature {
if !(0.0..=2.0).contains(&temp) {
errors.push(format!(
"Temperature {} is outside valid range (0.0-2.0)",
temp
));
}
}

// Check top_p if specified
if let Some(top_p) = agent.top_p {
if !(0.0..=1.0).contains(&top_p) {
errors.push(format!("Top-p {} is outside valid range (0.0-1.0)", top_p));
}
}

// Check for invalid tool references
let known_tools = [
"Read",
"Create",
"Edit",
"MultiEdit",
"LS",
"Grep",
"Glob",
"Execute",
"FetchUrl",
"WebSearch",
"TodoWrite",
"TodoRead",
"Task",
"ApplyPatch",
"CodeSearch",
"ViewImage",
"LspDiagnostics",
"LspHover",
"LspSymbols",
];

if let Some(ref allowed) = agent.allowed_tools {
for tool in allowed {
let tool_lower = tool.to_lowercase();
if !known_tools.iter().any(|t| t.to_lowercase() == tool_lower) {
warnings.push(format!("Unknown tool in allowed_tools: '{}'", tool));
}
}
}

for tool in &agent.denied_tools {
let tool_lower = tool.to_lowercase();
if !known_tools.iter().any(|t| t.to_lowercase() == tool_lower) {
warnings.push(format!("Unknown tool in denied_tools: '{}'", tool));
}
}

// Check for conflicting tool settings
if let Some(ref allowed) = agent.allowed_tools {
for tool in &agent.denied_tools {
if allowed
.iter()
.any(|t| t.to_lowercase() == tool.to_lowercase())
{
errors.push(format!(
"Tool '{}' is in both allowed_tools and denied_tools",
tool
));
}
}
}

// Check prompt is not too short (potential misconfiguration)
if let Some(ref prompt) = agent.prompt {
if prompt.trim().len() < 10 && !agent.native {
warnings.push("System prompt is very short (less than 10 characters)".to_string());
}
}

// Check max_turns
if let Some(turns) = agent.max_turns {
if turns == 0 {
errors.push("max_turns is set to 0, agent cannot complete any turns".to_string());
} else if turns > 1000 {
warnings.push(format!(
"max_turns is very high ({}), consider a lower limit",
turns
));
}
}

// Build result
let is_valid = errors.is_empty();

if args.json {
let result = serde_json::json!({
"name": agent.name,
"valid": is_valid,
"errors": errors,
"warnings": warnings,
"source": agent.source.to_string(),
"path": agent.path.as_ref().map(|p| p.display().to_string()),
});
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
println!("Agent Test: {}", agent.name);
println!("{}", "=".repeat(50));
println!();

if is_valid {
println!("Status: VALID");
} else {
println!("Status: INVALID");
}
println!("Source: {}", agent.source);
if let Some(ref path) = agent.path {
println!("Path: {}", path.display());
}

if !errors.is_empty() {
println!();
println!("Errors:");
for error in &errors {
println!(" {} {}", '\u{2717}', error); // X mark
}
}

if !warnings.is_empty() {
println!();
println!("Warnings:");
for warning in &warnings {
println!(" {} {}", '\u{26A0}', warning); // Warning sign
}
}

if errors.is_empty() && warnings.is_empty() {
println!();
println!("No issues found. Agent configuration is valid.");
}

println!();
if is_valid {
println!(
"Agent '{}' can be used with 'cortex -a {}'",
agent.name, agent.name
);
} else {
println!("Fix the errors above before using this agent.");
}
}

// Return error if validation failed
if !is_valid {
bail!("Agent validation failed with {} error(s)", errors.len());
}

Ok(())
}

/// Generate agent using AI.
async fn run_generate(args: CreateArgs) -> Result<()> {
use cortex_engine::agent::{AgentGenerator, GeneratedAgent};
Expand Down
42 changes: 42 additions & 0 deletions cortex-cli/src/debug_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,10 @@ async fn run_file(args: FileArgs) -> Result<()> {
std::env::current_dir()?.join(&args.path)
};

// Normalize path to remove "." and ".." components (#2911)
// This prevents displaying paths like "C:\Users\Leo\." when user runs "cortex debug file ."
let path = normalize_path(&path);

let exists = path.exists();

// Detect special file types using stat() BEFORE attempting any reads
Expand Down Expand Up @@ -857,6 +861,44 @@ fn detect_encoding_and_binary(path: &PathBuf) -> (Option<String>, Option<bool>)
(encoding, is_binary)
}

/// Normalize a path by removing "." and ".." components.
/// This is similar to std::fs::canonicalize but doesn't require the path to exist
/// and doesn't resolve symlinks.
fn normalize_path(path: &std::path::Path) -> PathBuf {
use std::path::Component;

let mut components = Vec::new();

for component in path.components() {
match component {
Component::CurDir => {
// Skip "." components
}
Component::ParentDir => {
// Go up one directory if possible
if !components.is_empty()
&& !matches!(components.last(), Some(Component::ParentDir))
{
components.pop();
} else {
components.push(component);
}
}
_ => {
components.push(component);
}
}
}

// Reconstruct the path
if components.is_empty() {
// If all components were removed (e.g., for "."), return the current directory
PathBuf::from(".")
} else {
components.iter().collect()
}
}

/// Format file size in human-readable format.
fn format_size(bytes: u64) -> String {
const KB: u64 = 1024;
Expand Down
Loading