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
338 changes: 292 additions & 46 deletions cortex-cli/src/acp_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AcpSubcommand>,

// Default server arguments (when no subcommand is provided)
/// Working directory for the session.
#[arg(long = "cwd", short = 'C', value_name = "DIR")]
pub cwd: Option<PathBuf>,
Expand Down Expand Up @@ -53,70 +57,312 @@ pub struct AcpCli {
pub deny_tools: Vec<String>,
}

/// 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<PathBuf>,

/// 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<String>,

/// Agent to use.
#[arg(long = "agent")]
pub agent: Option<String>,

/// Tools to allow (whitelist).
#[arg(long = "allow-tool", action = clap::ArgAction::Append)]
pub allow_tools: Vec<String>,

/// Tools to deny (blacklist).
#[arg(long = "deny-tool", action = clap::ArgAction::Append)]
pub deny_tools: Vec<String>,
}

/// 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<AcpServerInfo>,
}

#[derive(Serialize)]
struct AcpServerInfo {
pid: u32,
port: Option<u16>,
transport: String,
uptime: Option<String>,
}

// 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::<u32>() {
// 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::<u32>() {
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<u16> {
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::<u16>() {
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<u16> {
// 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<String> {
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<String> {
None
}
2 changes: 1 addition & 1 deletion cortex-cli/src/debug_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!();

Expand Down
Loading