diff --git a/cortex-cli/src/agent_cmd.rs b/cortex-cli/src/agent_cmd.rs index 78bf5f42..d4f15611 100644 --- a/cortex-cli/src/agent_cmd.rs +++ b/cortex-cli/src/agent_cmd.rs @@ -46,6 +46,12 @@ pub enum AgentSubcommand { /// Export an agent definition to stdout or a file. Export(ExportArgs), + + /// Import an agent definition from a file. + Import(ImportArgs), + + /// Compare two agents by running the same prompt with both. + Compare(CompareArgs), } /// Arguments for list command. @@ -76,6 +82,11 @@ pub struct ListArgs { #[arg(long)] pub filter: Option, + /// Filter agents by category. + /// Example: --category development + #[arg(long)] + pub category: Option, + /// Output only agent names (one per line) for shell completion. #[arg(long, hide = true)] pub names_only: bool, @@ -206,6 +217,39 @@ pub struct ExportArgs { pub json: bool, } +/// Arguments for import command. +#[derive(Debug, Parser)] +pub struct ImportArgs { + /// Path to the agent file to import. + pub path: PathBuf, + + /// Force overwrite if agent already exists. + #[arg(short, long)] + pub force: bool, + + /// New name for the imported agent (defaults to name from file). + #[arg(long)] + pub name: Option, +} + +/// Arguments for compare command. +#[derive(Debug, Parser)] +pub struct CompareArgs { + /// First agent to compare. + pub agent1: String, + + /// Second agent to compare. + pub agent2: String, + + /// Prompt to test with both agents. + #[arg(long, short)] + pub prompt: 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")] @@ -269,6 +313,9 @@ impl std::fmt::Display for AgentSource { pub struct AgentFrontmatter { /// Agent name (unique identifier). pub name: String, + /// Agent version (e.g., "1.0.0"). + #[serde(default)] + pub version: Option, /// Description of what this agent does. #[serde(default)] pub description: Option, @@ -287,19 +334,23 @@ pub struct AgentFrontmatter { /// Maximum tokens for response. #[serde(default)] pub max_tokens: Option, - /// Allowed tools (None means all tools). + /// Allowed tools (None means all tools). Supports glob patterns like "File*". #[serde(default, alias = "allowed-tools")] pub allowed_tools: Option>, - /// Denied tools. + /// Denied tools. Supports glob patterns like "File*". + /// Note: denied_tools takes precedence over allowed_tools if a tool matches both. #[serde(default, alias = "denied-tools")] pub denied_tools: Vec, /// Tags for categorization. #[serde(default)] pub tags: Vec, + /// Category for organization (e.g., "development", "testing", "devops"). + #[serde(default)] + pub category: Option, /// Whether agent can spawn sub-agents. #[serde(default = "default_can_delegate")] pub can_delegate: bool, - /// Maximum number of turns. + /// Maximum number of turns. Values above 1000 will trigger a warning. #[serde(default)] pub max_turns: Option, /// Display name (for UI). @@ -314,6 +365,9 @@ pub struct AgentFrontmatter { /// Additional tools configuration (tool_name -> enabled). #[serde(default)] pub tools: HashMap, + /// Required MCP servers for this agent. + #[serde(default, alias = "requires-mcp")] + pub requires_mcp: Vec, } fn default_can_delegate() -> bool { @@ -325,6 +379,9 @@ fn default_can_delegate() -> bool { pub struct AgentInfo { /// Agent name. pub name: String, + /// Agent version. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, /// Display name. pub display_name: Option, /// Description. @@ -349,18 +406,28 @@ pub struct AgentInfo { /// Empty means no tool-specific overrides. #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub tools: HashMap, - /// Allowed tools. + /// Allowed tools. Supports glob patterns (e.g., "File*"). pub allowed_tools: Option>, - /// Denied tools. + /// Denied tools. Supports glob patterns (e.g., "File*"). + /// Note: denied_tools takes precedence over allowed_tools. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub denied_tools: Vec, /// Maximum turns. pub max_turns: Option, + /// Maximum tokens for response (token budget). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, /// Can delegate to sub-agents. pub can_delegate: bool, /// Tags. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tags: Vec, + /// Category for organization. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub category: Option, + /// Required MCP servers. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub requires_mcp: Vec, /// Source of the agent. pub source: AgentSource, /// Path to agent definition file. @@ -379,6 +446,8 @@ impl AgentCli { AgentSubcommand::Install(args) => run_install(args).await, AgentSubcommand::Copy(args) => run_copy(args).await, AgentSubcommand::Export(args) => run_export(args).await, + AgentSubcommand::Import(args) => run_import(args).await, + AgentSubcommand::Compare(args) => run_compare(args).await, } } } @@ -520,6 +589,7 @@ fn load_builtin_agents() -> Vec { vec![ AgentInfo { name: "build".to_string(), + version: Some("1.0.0".to_string()), display_name: Some("Build".to_string()), description: Some("Full access agent for development work".to_string()), mode: AgentMode::Primary, @@ -534,13 +604,17 @@ fn load_builtin_agents() -> Vec { allowed_tools: None, denied_tools: Vec::new(), max_turns: None, + max_tokens: None, can_delegate: true, tags: vec!["development".to_string()], + category: Some("development".to_string()), + requires_mcp: Vec::new(), source: AgentSource::Builtin, path: None, }, AgentInfo { name: "plan".to_string(), + version: Some("1.0.0".to_string()), display_name: Some("Plan".to_string()), description: Some("Read-only agent for analysis and code exploration".to_string()), mode: AgentMode::Primary, @@ -560,13 +634,17 @@ fn load_builtin_agents() -> Vec { ]), denied_tools: vec!["Execute".to_string(), "Create".to_string(), "Edit".to_string()], max_turns: None, + max_tokens: None, can_delegate: false, tags: vec!["analysis".to_string(), "read-only".to_string()], + category: Some("analysis".to_string()), + requires_mcp: Vec::new(), source: AgentSource::Builtin, path: None, }, AgentInfo { name: "explore".to_string(), + version: Some("1.0.0".to_string()), display_name: Some("Explore".to_string()), description: Some("Fast agent specialized for exploring codebases".to_string()), mode: AgentMode::Subagent, @@ -591,13 +669,17 @@ fn load_builtin_agents() -> Vec { ]), denied_tools: Vec::new(), max_turns: Some(10), + max_tokens: None, can_delegate: false, tags: vec!["code".to_string(), "analysis".to_string()], + category: Some("analysis".to_string()), + requires_mcp: Vec::new(), source: AgentSource::Builtin, path: None, }, AgentInfo { name: "general".to_string(), + version: Some("1.0.0".to_string()), display_name: Some("General".to_string()), description: Some("General-purpose agent for researching complex questions and executing multi-step tasks".to_string()), mode: AgentMode::Subagent, @@ -615,13 +697,17 @@ fn load_builtin_agents() -> Vec { allowed_tools: None, denied_tools: Vec::new(), max_turns: None, + max_tokens: None, can_delegate: true, tags: vec!["general".to_string()], + category: Some("general".to_string()), + requires_mcp: Vec::new(), source: AgentSource::Builtin, path: None, }, AgentInfo { name: "code-explorer".to_string(), + version: Some("1.0.0".to_string()), display_name: Some("Code Explorer".to_string()), description: Some("Explore and understand codebases. Use for analyzing code structure, finding patterns, and understanding implementations.".to_string()), mode: AgentMode::Subagent, @@ -641,13 +727,17 @@ fn load_builtin_agents() -> Vec { ]), denied_tools: Vec::new(), max_turns: Some(10), + max_tokens: None, can_delegate: false, tags: vec!["code".to_string(), "analysis".to_string()], + category: Some("analysis".to_string()), + requires_mcp: Vec::new(), source: AgentSource::Builtin, path: None, }, AgentInfo { name: "code-reviewer".to_string(), + version: Some("1.0.0".to_string()), display_name: Some("Code Reviewer".to_string()), description: Some("Review code for quality, bugs, and best practices".to_string()), mode: AgentMode::Subagent, @@ -666,13 +756,17 @@ fn load_builtin_agents() -> Vec { ]), denied_tools: vec!["Execute".to_string()], max_turns: Some(5), + max_tokens: None, can_delegate: false, tags: vec!["review".to_string(), "quality".to_string()], + category: Some("review".to_string()), + requires_mcp: Vec::new(), source: AgentSource::Builtin, path: None, }, AgentInfo { name: "architect".to_string(), + version: Some("1.0.0".to_string()), display_name: Some("Architect".to_string()), description: Some("Design software architecture and make high-level technical decisions".to_string()), mode: AgentMode::Subagent, @@ -692,8 +786,11 @@ fn load_builtin_agents() -> Vec { ]), denied_tools: vec!["Execute".to_string()], max_turns: Some(15), + max_tokens: None, can_delegate: true, tags: vec!["architecture".to_string(), "design".to_string()], + category: Some("architecture".to_string()), + requires_mcp: Vec::new(), source: AgentSource::Builtin, path: None, }, @@ -811,13 +908,41 @@ fn read_file_with_encoding(path: &Path) -> Result { } /// Load an agent from a markdown file with YAML frontmatter. +/// Maximum recommended value for max_turns to prevent runaway sessions. +const MAX_TURNS_WARNING_THRESHOLD: u32 = 1000; + fn load_agent_from_md(path: &Path, source: AgentSource) -> Result { let content = read_file_with_encoding(path)?; let (frontmatter, body) = parse_frontmatter(&content)?; + // Validate max_turns - warn if above threshold (#3030) + if let Some(max_turns) = frontmatter.max_turns { + if max_turns > MAX_TURNS_WARNING_THRESHOLD { + eprintln!( + "Warning: Agent '{}' has max_turns={} which exceeds recommended maximum of {}. \ + This could lead to runaway sessions and high costs.", + frontmatter.name, max_turns, MAX_TURNS_WARNING_THRESHOLD + ); + } + } + + // Check for tool conflicts - warn if tool is in both allowed and denied lists (#3028) + if let Some(ref allowed) = frontmatter.allowed_tools { + for tool in allowed { + if tool_matches_any(tool, &frontmatter.denied_tools) { + eprintln!( + "Warning: Agent '{}' has tool '{}' in both allowed_tools and denied_tools. \ + Denied tools take precedence.", + frontmatter.name, tool + ); + } + } + } + Ok(AgentInfo { name: frontmatter.name, + version: frontmatter.version, display_name: frontmatter.display_name, description: frontmatter.description, mode: frontmatter.mode, @@ -832,13 +957,60 @@ fn load_agent_from_md(path: &Path, source: AgentSource) -> Result { allowed_tools: frontmatter.allowed_tools, denied_tools: frontmatter.denied_tools, max_turns: frontmatter.max_turns, + max_tokens: frontmatter.max_tokens, can_delegate: frontmatter.can_delegate, tags: frontmatter.tags, + category: frontmatter.category, + requires_mcp: frontmatter.requires_mcp, source, path: Some(path.to_path_buf()), }) } +/// Check if a tool name matches any pattern in the list. +/// Supports glob patterns (e.g., "File*" matches "FileRead", "FileWrite"). +fn tool_matches_any(tool: &str, patterns: &[String]) -> bool { + for pattern in patterns { + if tool_matches_pattern(tool, pattern) { + return true; + } + } + false +} + +/// Check if a tool name matches a glob pattern. +/// Supports simple glob patterns: * for any sequence of characters. +fn tool_matches_pattern(tool: &str, pattern: &str) -> bool { + let tool_lower = tool.to_lowercase(); + let pattern_lower = pattern.to_lowercase(); + + if pattern_lower.contains('*') { + // Simple glob matching + if pattern_lower.starts_with('*') && pattern_lower.ends_with('*') { + // *pattern* - contains + let inner = &pattern_lower[1..pattern_lower.len() - 1]; + tool_lower.contains(inner) + } else if pattern_lower.starts_with('*') { + // *pattern - ends with + tool_lower.ends_with(&pattern_lower[1..]) + } else if pattern_lower.ends_with('*') { + // pattern* - starts with + tool_lower.starts_with(&pattern_lower[..pattern_lower.len() - 1]) + } else { + // pattern*suffix - match prefix and suffix + let parts: Vec<&str> = pattern_lower.splitn(2, '*').collect(); + if parts.len() == 2 { + tool_lower.starts_with(parts[0]) && tool_lower.ends_with(parts[1]) + } else { + tool_lower == pattern_lower + } + } + } else { + // Exact match + tool_lower == pattern_lower + } +} + /// Load an agent from a JSON file. fn load_agent_from_json(path: &Path, source: AgentSource) -> Result { let content = read_file_with_encoding(path)?; @@ -846,6 +1018,30 @@ fn load_agent_from_json(path: &Path, source: AgentSource) -> Result { let frontmatter: AgentFrontmatter = serde_json::from_str(&content) .with_context(|| format!("Failed to parse {}", path.display()))?; + // Validate max_turns - warn if above threshold (#3030) + if let Some(max_turns) = frontmatter.max_turns { + if max_turns > MAX_TURNS_WARNING_THRESHOLD { + eprintln!( + "Warning: Agent '{}' has max_turns={} which exceeds recommended maximum of {}. \ + This could lead to runaway sessions and high costs.", + frontmatter.name, max_turns, MAX_TURNS_WARNING_THRESHOLD + ); + } + } + + // Check for tool conflicts - warn if tool is in both allowed and denied lists (#3028) + if let Some(ref allowed) = frontmatter.allowed_tools { + for tool in allowed { + if tool_matches_any(tool, &frontmatter.denied_tools) { + eprintln!( + "Warning: Agent '{}' has tool '{}' in both allowed_tools and denied_tools. \ + Denied tools take precedence.", + frontmatter.name, tool + ); + } + } + } + // Check for a separate prompt file let prompt = if let Some(parent) = path.parent() { let prompt_file = parent.join("prompt.md"); @@ -860,6 +1056,7 @@ fn load_agent_from_json(path: &Path, source: AgentSource) -> Result { Ok(AgentInfo { name: frontmatter.name, + version: frontmatter.version, display_name: frontmatter.display_name, description: frontmatter.description, mode: frontmatter.mode, @@ -874,8 +1071,11 @@ fn load_agent_from_json(path: &Path, source: AgentSource) -> Result { allowed_tools: frontmatter.allowed_tools, denied_tools: frontmatter.denied_tools, max_turns: frontmatter.max_turns, + max_tokens: frontmatter.max_tokens, can_delegate: frontmatter.can_delegate, tags: frontmatter.tags, + category: frontmatter.category, + requires_mcp: frontmatter.requires_mcp, source, path: Some(path.to_path_buf()), }) @@ -964,6 +1164,17 @@ async fn run_list(args: ListArgs) -> Result<()> { return false; } } + // Filter by category (#3035) + if let Some(ref cat) = args.category { + match &a.category { + Some(agent_cat) => { + if !agent_cat.to_lowercase().contains(&cat.to_lowercase()) { + return false; + } + } + None => return false, + } + } true }) .collect(); @@ -1156,7 +1367,20 @@ async fn run_show(args: ShowArgs) -> Result<()> { } if let Some(max_turns) = agent.max_turns { - println!("Max Turns: {max_turns}"); + let warning = if max_turns > MAX_TURNS_WARNING_THRESHOLD { + " (WARNING: exceeds recommended max of 1000)" + } else { + "" + }; + println!("Max Turns: {max_turns}{warning}"); + } + + if let Some(max_tokens) = agent.max_tokens { + println!("Max Tokens: {max_tokens}"); + } + + if let Some(ref version) = agent.version { + println!("Version: {version}"); } if let Some(ref color) = agent.color { @@ -1169,12 +1393,22 @@ async fn run_show(args: ShowArgs) -> Result<()> { println!("Tags: {}", agent.tags.join(", ")); } + if let Some(ref category) = agent.category { + println!("Category: {category}"); + } + if let Some(ref allowed) = agent.allowed_tools { - println!("Allowed Tools: {}", allowed.join(", ")); + println!( + "Allowed Tools: {} (supports glob patterns)", + allowed.join(", ") + ); } if !agent.denied_tools.is_empty() { - println!("Denied Tools: {}", agent.denied_tools.join(", ")); + println!( + "Denied Tools: {} (supports glob patterns, takes precedence over allowed)", + agent.denied_tools.join(", ") + ); } if !agent.tools.is_empty() { @@ -1187,6 +1421,10 @@ async fn run_show(args: ShowArgs) -> Result<()> { } } + if !agent.requires_mcp.is_empty() { + println!("Required MCP Servers: {}", agent.requires_mcp.join(", ")); + } + match &agent.path { Some(path) => println!("Path: {}", path.display()), None if agent.native => println!("Path: (builtin)"), @@ -2324,6 +2562,25 @@ name: {} frontmatter.push_str(&format!("max_turns: {}\n", max_turns)); } + if let Some(max_tokens) = agent.max_tokens { + frontmatter.push_str(&format!("max_tokens: {}\n", max_tokens)); + } + + if let Some(ref version) = agent.version { + frontmatter.push_str(&format!("version: {}\n", version)); + } + + if let Some(ref category) = agent.category { + frontmatter.push_str(&format!("category: {}\n", category)); + } + + if !agent.requires_mcp.is_empty() { + frontmatter.push_str("requires_mcp:\n"); + for server in &agent.requires_mcp { + frontmatter.push_str(&format!(" - {}\n", server)); + } + } + frontmatter.push_str(&format!("hidden: {}\n", agent.hidden)); frontmatter.push_str("---\n\n"); @@ -2351,6 +2608,243 @@ name: {} Ok(()) } +/// Import an agent from a file. +async fn run_import(args: ImportArgs) -> Result<()> { + // Validate the file exists + if !args.path.exists() { + bail!("File not found: {}", args.path.display()); + } + + // Determine file type and load the agent + let ext = args.path.extension().and_then(|e| e.to_str()).unwrap_or(""); + let source = AgentSource::Personal; // Imported agents go to personal directory + + let mut agent = match ext { + "json" => load_agent_from_json(&args.path, source)?, + "md" | "markdown" => load_agent_from_md(&args.path, source)?, + _ => { + // Try to detect based on content + let content = std::fs::read_to_string(&args.path) + .with_context(|| format!("Failed to read file: {}", args.path.display()))?; + if content.trim().starts_with("---") { + load_agent_from_md(&args.path, source)? + } else if content.trim().starts_with('{') { + load_agent_from_json(&args.path, source)? + } else { + bail!("Unknown file format. Use .md for markdown or .json for JSON format."); + } + } + }; + + // Override name if provided + if let Some(new_name) = args.name { + agent.name = new_name; + } + + // Validate agent name + if agent.name.trim().is_empty() { + bail!("Agent name cannot be empty"); + } + + if !agent + .name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + bail!("Agent name must contain only alphanumeric characters, hyphens, and underscores"); + } + + // Get destination path + let agents_dir = get_agents_dir()?; + std::fs::create_dir_all(&agents_dir)?; + + let dest_file = agents_dir.join(format!("{}.md", agent.name)); + + // Check if agent already exists + if dest_file.exists() && !args.force { + bail!( + "Agent '{}' already exists at {}.\nUse --force to overwrite.", + agent.name, + dest_file.display() + ); + } + + // Read source content and write to destination + let content = std::fs::read_to_string(&args.path) + .with_context(|| format!("Failed to read file: {}", args.path.display()))?; + + // If name was overridden, update it in the content + let final_content = if args.name.is_some() { + // Parse and rebuild with new name + if content.trim().starts_with("---") { + let (mut fm, body) = parse_frontmatter(&content)?; + fm.name = agent.name.clone(); + let yaml = serde_yaml::to_string(&fm)?; + format!("---\n{}---\n\n{}\n", yaml, body) + } else { + // JSON - parse and update name + let mut json_value: serde_json::Value = serde_json::from_str(&content)?; + if let serde_json::Value::Object(ref mut map) = json_value { + map.insert( + "name".to_string(), + serde_json::Value::String(agent.name.clone()), + ); + } + serde_json::to_string_pretty(&json_value)? + } + } else { + content + }; + + std::fs::write(&dest_file, &final_content) + .with_context(|| format!("Failed to write agent file: {}", dest_file.display()))?; + + println!("Agent '{}' imported successfully!", agent.name); + println!(" Location: {}", dest_file.display()); + println!(); + println!(" Use 'cortex agent show {}' to view details.", agent.name); + + Ok(()) +} + +/// Compare two agents by running the same prompt with both. +async fn run_compare(args: CompareArgs) -> Result<()> { + let agents = load_all_agents()?; + + // Find both agents + let agent1 = agents + .iter() + .find(|a| a.name == args.agent1) + .ok_or_else(|| anyhow::anyhow!("Agent '{}' not found", args.agent1))?; + + let agent2 = agents + .iter() + .find(|a| a.name == args.agent2) + .ok_or_else(|| anyhow::anyhow!("Agent '{}' not found", args.agent2))?; + + // Display comparison info + if args.json { + let comparison = serde_json::json!({ + "agent1": { + "name": agent1.name, + "display_name": agent1.display_name, + "description": agent1.description, + "mode": format!("{}", agent1.mode), + "model": agent1.model, + "temperature": agent1.temperature, + "max_turns": agent1.max_turns, + "max_tokens": agent1.max_tokens, + "category": agent1.category, + "version": agent1.version, + }, + "agent2": { + "name": agent2.name, + "display_name": agent2.display_name, + "description": agent2.description, + "mode": format!("{}", agent2.mode), + "model": agent2.model, + "temperature": agent2.temperature, + "max_turns": agent2.max_turns, + "max_tokens": agent2.max_tokens, + "category": agent2.category, + "version": agent2.version, + }, + "prompt": args.prompt, + "note": "Full comparison with actual responses requires running both agents. Use 'cortex exec --agent ' to test each agent individually." + }); + println!("{}", serde_json::to_string_pretty(&comparison)?); + } else { + println!("Agent Comparison"); + println!("{}", "=".repeat(60)); + println!(); + println!("Comparing: {} vs {}", agent1.name, agent2.name); + println!("Prompt: {}", args.prompt); + println!(); + + // Display side-by-side comparison + println!("{:<30} {:<30}", "Agent 1", "Agent 2"); + println!("{}", "-".repeat(60)); + println!( + "{:<30} {:<30}", + format!("Name: {}", agent1.name), + format!("Name: {}", agent2.name) + ); + println!( + "{:<30} {:<30}", + format!("Mode: {}", agent1.mode), + format!("Mode: {}", agent2.mode) + ); + println!( + "{:<30} {:<30}", + format!("Model: {}", agent1.model.as_deref().unwrap_or("default")), + format!("Model: {}", agent2.model.as_deref().unwrap_or("default")) + ); + println!( + "{:<30} {:<30}", + format!( + "Temp: {}", + agent1 + .temperature + .map(|t| t.to_string()) + .unwrap_or_else(|| "default".to_string()) + ), + format!( + "Temp: {}", + agent2 + .temperature + .map(|t| t.to_string()) + .unwrap_or_else(|| "default".to_string()) + ) + ); + println!( + "{:<30} {:<30}", + format!( + "Max Turns: {}", + agent1 + .max_turns + .map(|t| t.to_string()) + .unwrap_or_else(|| "none".to_string()) + ), + format!( + "Max Turns: {}", + agent2 + .max_turns + .map(|t| t.to_string()) + .unwrap_or_else(|| "none".to_string()) + ) + ); + println!( + "{:<30} {:<30}", + format!( + "Max Tokens: {}", + agent1 + .max_tokens + .map(|t| t.to_string()) + .unwrap_or_else(|| "none".to_string()) + ), + format!( + "Max Tokens: {}", + agent2 + .max_tokens + .map(|t| t.to_string()) + .unwrap_or_else(|| "none".to_string()) + ) + ); + println!( + "{:<30} {:<30}", + format!("Category: {}", agent1.category.as_deref().unwrap_or("none")), + format!("Category: {}", agent2.category.as_deref().unwrap_or("none")) + ); + + println!(); + println!("Note: To test actual responses, run each agent with the prompt:"); + println!(" cortex exec --agent {} \"{}\"", agent1.name, args.prompt); + println!(" cortex exec --agent {} \"{}\"", agent2.name, args.prompt); + } + + Ok(()) +} + /// Format a hex color as an ANSI-colored preview block. /// /// Converts a hex color like "#FF5733" to an ANSI escape sequence that diff --git a/cortex-cli/src/debug_cmd.rs b/cortex-cli/src/debug_cmd.rs index e95934ad..9ec0184a 100644 --- a/cortex-cli/src/debug_cmd.rs +++ b/cortex-cli/src/debug_cmd.rs @@ -917,6 +917,8 @@ struct LspServerInfo { installed: bool, version: Option, path: Option, + /// Source of the server definition: "builtin" or "config". + source: String, } /// LSP debug output. @@ -939,7 +941,7 @@ struct LspConnectionTest { } async fn run_lsp(args: LspArgs) -> Result<()> { - // Known LSP servers + // Known LSP servers (builtin) let known_servers = vec![ ("rust-analyzer", "Rust", "rust-analyzer"), ( @@ -966,6 +968,7 @@ async fn run_lsp(args: LspArgs) -> Result<()> { let mut servers = Vec::new(); + // Add builtin servers for (name, language, command) in known_servers { let (installed, path, version) = check_command_installed(command).await; servers.push(LspServerInfo { @@ -975,9 +978,110 @@ async fn run_lsp(args: LspArgs) -> Result<()> { installed, version, path, + source: "builtin".to_string(), }); } + // Load custom LSP servers from config (#3043) + // Check for lsp.servers section in config.toml + let cortex_home = get_cortex_home(); + let config_path = cortex_home.join("config.toml"); + if config_path.exists() { + if let Ok(content) = std::fs::read_to_string(&config_path) { + if let Ok(config_toml) = content.parse::() { + if let Some(toml::Value::Table(lsp)) = config_toml.get("lsp") { + if let Some(toml::Value::Table(custom_servers)) = lsp.get("servers") { + for (name, value) in custom_servers { + // Support both simple string format and table format: + // [lsp.servers] + // my-lsp = "/path/to/my-lsp" + // my-lsp-2 = { command = "/path/to/my-lsp", language = "MyLang" } + let (command, language) = match value { + toml::Value::String(cmd) => (cmd.clone(), "Custom".to_string()), + toml::Value::Table(t) => { + let cmd = t + .get("command") + .and_then(|v| v.as_str()) + .unwrap_or(name) + .to_string(); + let lang = t + .get("language") + .and_then(|v| v.as_str()) + .unwrap_or("Custom") + .to_string(); + (cmd, lang) + } + _ => continue, + }; + + let (installed, path, version) = + check_command_installed(&command).await; + servers.push(LspServerInfo { + name: name.clone(), + language, + command, + installed, + version, + path, + source: "config".to_string(), + }); + } + } + } + } + } + } + + // Also check project config + let cwd = std::env::current_dir().unwrap_or_default(); + let project_config_path = cwd.join(".cortex").join("config.toml"); + if project_config_path.exists() { + if let Ok(content) = std::fs::read_to_string(&project_config_path) { + if let Ok(config_toml) = content.parse::() { + if let Some(toml::Value::Table(lsp)) = config_toml.get("lsp") { + if let Some(toml::Value::Table(custom_servers)) = lsp.get("servers") { + for (name, value) in custom_servers { + // Skip if already defined + if servers.iter().any(|s| s.name == *name) { + continue; + } + + let (command, language) = match value { + toml::Value::String(cmd) => (cmd.clone(), "Custom".to_string()), + toml::Value::Table(t) => { + let cmd = t + .get("command") + .and_then(|v| v.as_str()) + .unwrap_or(name) + .to_string(); + let lang = t + .get("language") + .and_then(|v| v.as_str()) + .unwrap_or("Custom") + .to_string(); + (cmd, lang) + } + _ => continue, + }; + + let (installed, path, version) = + check_command_installed(&command).await; + servers.push(LspServerInfo { + name: name.clone(), + language, + command, + installed, + version, + path, + source: "project-config".to_string(), + }); + } + } + } + } + } + } + // Filter if specific server requested if let Some(ref server_name) = args.server { servers.retain(|s| s.name.to_lowercase().contains(&server_name.to_lowercase())); @@ -1010,9 +1114,12 @@ async fn run_lsp(args: LspArgs) -> Result<()> { println!("{}", serde_json::to_string_pretty(&output)?); } else { println!("LSP Servers"); - println!("{}", "=".repeat(60)); - println!("{:<30} {:<15} {:<10}", "Server", "Language", "Status"); - println!("{}", "-".repeat(60)); + println!("{}", "=".repeat(70)); + println!( + "{:<25} {:<15} {:<12} {:<10}", + "Server", "Language", "Status", "Source" + ); + println!("{}", "-".repeat(70)); for server in &output.servers { let status = if server.installed { @@ -1020,7 +1127,10 @@ async fn run_lsp(args: LspArgs) -> Result<()> { } else { "not found" }; - println!("{:<30} {:<15} {:<10}", server.name, server.language, status); + println!( + "{:<25} {:<15} {:<12} {:<10}", + server.name, server.language, status, server.source + ); if let Some(ref path) = server.path { println!(" Path: {}", path.display()); } @@ -1041,6 +1151,14 @@ async fn run_lsp(args: LspArgs) -> Result<()> { println!(" Error: {}", error); } } + + // Print help for adding custom servers (#3043) + println!(); + println!("To add custom LSP servers, add to config.toml:"); + println!(" [lsp.servers]"); + println!(" my-lsp = \"/path/to/my-lsp\""); + println!(" # Or with more options:"); + println!(" my-lsp = {{ command = \"/path/to/my-lsp\", language = \"MyLang\" }}"); } Ok(())