use std::io::Write;
use std::path::PathBuf;
#[cfg(unix)]
use std::time::Duration;
use clap::Parser;
#[cfg(unix)]
use winx_code_agent::daemon::{default_socket_path, DaemonClient};
#[cfg(unix)]
use winx_code_agent::runtime::{configured_daemon_binary, restart_control_daemon_at};
use winx_code_agent::{start_winx_server, Result, WinxError};
#[derive(Parser)]
#[command(name = "winx-code-agent")]
#[command(author = "Gabriel Maia")]
#[command(version)]
#[command(
about = "Remote-first MCP runtime with Streamable HTTP, durable PTYs, and guarded shell/file tools",
long_about = None
)]
struct Cli {
#[arg(short, long)]
verbose: bool,
#[arg(long)]
debug: bool,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(clap::Subcommand)]
enum Commands {
Serve {
#[arg(long)]
http: bool,
#[arg(long, default_value = "127.0.0.1:8000")]
bind: String,
#[arg(long, conflicts_with_all = ["token_file", "principal_config"])]
token: Option<String>,
#[arg(long, value_name = "PATH", conflicts_with_all = ["token", "principal_config"])]
token_file: Option<PathBuf>,
#[arg(long, value_name = "PATH", conflicts_with_all = ["token", "token_file"])]
principal_config: Option<PathBuf>,
#[arg(long)]
allow_weak_token: bool,
#[arg(long)]
allow_non_loopback: bool,
#[arg(
long,
value_enum,
default_value_t = winx_code_agent::config::HttpSessionAffinity::Workspace
)]
session_affinity: winx_code_agent::config::HttpSessionAffinity,
#[arg(
long,
default_value_t = winx_code_agent::http_server::DEFAULT_MAX_CONCURRENCY
)]
max_concurrency: usize,
#[arg(
long,
default_value_t = winx_code_agent::http_server::DEFAULT_REQUESTS_PER_MINUTE
)]
requests_per_minute: u32,
#[arg(long = "allowed-host")]
allowed_host: Vec<String>,
#[arg(long)]
allow_query_token: bool,
},
#[cfg(unix)]
List {
#[arg(long)]
socket: Option<PathBuf>,
},
#[cfg(unix)]
Attach {
thread_id: String,
#[arg(long, default_value = "cli")]
consumer: String,
#[arg(long)]
follow: bool,
#[arg(long)]
socket: Option<PathBuf>,
},
#[cfg(unix)]
Kill {
#[arg(required_unless_present = "all")]
thread_id: Option<String>,
#[arg(long, conflicts_with = "thread_id")]
all: bool,
#[arg(long)]
socket: Option<PathBuf>,
},
#[cfg(unix)]
Prune {
#[arg(long)]
idle_seconds: Option<u64>,
#[arg(long)]
socket: Option<PathBuf>,
},
#[cfg(unix)]
RestartDaemon {
#[arg(long)]
socket: Option<PathBuf>,
},
Doctor,
}
fn setup_logging(verbose: bool, debug: bool) {
let level = if debug {
tracing::Level::DEBUG
} else if verbose {
tracing::Level::INFO
} else {
tracing::Level::WARN
};
let filter = tracing_subscriber::EnvFilter::from_default_env().add_directive(level.into());
let filter = match "winx::usage=info".parse() {
Ok(directive) => filter.add_directive(directive),
Err(_) => filter,
};
let builder = tracing_subscriber::fmt().with_env_filter(filter).with_writer(std::io::stderr);
if winx_code_agent::config::env_text("WINX_LOG_FORMAT")
.is_some_and(|format| format.eq_ignore_ascii_case("json"))
{
builder.json().init();
} else {
builder.with_ansi(true).init();
}
}
fn main() -> Result<()> {
let cli = Cli::parse();
setup_logging(cli.verbose, cli.debug);
winx_code_agent::sandbox::apply_if_requested();
tokio::runtime::Runtime::new()
.map_err(|e| {
WinxError::ShellInitializationError(format!("failed to start tokio runtime: {e}"))
})?
.block_on(async_main(cli))
}
async fn async_main(cli: Cli) -> Result<()> {
match cli.command {
Some(Commands::Serve {
http: true,
bind,
token,
token_file,
principal_config,
allow_weak_token,
allow_non_loopback,
session_affinity,
max_concurrency,
requests_per_minute,
allowed_host,
allow_query_token,
}) => {
run_http_server(
bind,
token,
token_file,
principal_config,
allow_weak_token,
allow_non_loopback,
session_affinity,
max_concurrency,
requests_per_minute,
allowed_host,
allow_query_token,
)
.await
}
#[cfg(unix)]
Some(Commands::List { socket }) => run_list(socket).await,
#[cfg(unix)]
Some(Commands::Attach { thread_id, consumer, follow, socket }) => {
run_attach(socket, thread_id, consumer, follow).await
}
#[cfg(unix)]
Some(Commands::Kill { thread_id, all, socket }) => run_kill(socket, thread_id, all).await,
#[cfg(unix)]
Some(Commands::Prune { idle_seconds, socket }) => run_prune(socket, idle_seconds).await,
#[cfg(unix)]
Some(Commands::RestartDaemon { socket }) => run_restart_daemon(socket).await,
Some(Commands::Doctor) => run_doctor().await,
None | Some(Commands::Serve { .. }) => run_server().await,
}
}
#[cfg(unix)]
fn daemon_client(socket: Option<PathBuf>) -> DaemonClient {
DaemonClient::new(socket.unwrap_or_else(default_socket_path))
}
#[cfg(unix)]
async fn run_list(socket: Option<PathBuf>) -> Result<()> {
let sessions = daemon_client(socket).list_sessions().await?;
serde_json::to_writer_pretty(std::io::stdout().lock(), &sessions)
.map_err(|error| WinxError::SerializationError(error.to_string()))?;
writeln!(std::io::stdout().lock())?;
Ok(())
}
#[cfg(unix)]
async fn run_attach(
socket: Option<PathBuf>,
thread_id: String,
consumer: String,
follow: bool,
) -> Result<()> {
let client = daemon_client(socket);
loop {
let read = client.read_output(&thread_id, &consumer).await?;
if read.gap {
writeln!(std::io::stderr().lock(), "(...daemon scrollback truncated...)")?;
}
if !read.output.is_empty() {
let mut stdout = std::io::stdout().lock();
stdout.write_all(read.output.as_bytes())?;
stdout.flush()?;
}
if !follow {
return Ok(());
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
#[cfg(unix)]
async fn run_kill(socket: Option<PathBuf>, thread_id: Option<String>, all: bool) -> Result<()> {
let client = daemon_client(socket);
if all {
let sessions = client.list_sessions().await?;
let mut killed = 0usize;
for session in sessions {
if client.kill_session(&session.thread_id).await? {
killed += 1;
}
}
writeln!(std::io::stdout().lock(), "killed {killed} session(s)")?;
return Ok(());
}
let thread_id = thread_id
.ok_or_else(|| WinxError::InvalidInput("provide a thread_id or pass --all".to_string()))?;
let killed = client.kill_session(&thread_id).await?;
writeln!(std::io::stdout().lock(), "{}", if killed { "killed" } else { "not found" })?;
Ok(())
}
#[cfg(unix)]
async fn run_prune(socket: Option<PathBuf>, idle_seconds: Option<u64>) -> Result<()> {
let result = daemon_client(socket).prune_sessions(idle_seconds).await?;
serde_json::to_writer_pretty(std::io::stdout().lock(), &result)
.map_err(|error| WinxError::SerializationError(error.to_string()))?;
writeln!(std::io::stdout().lock())?;
Ok(())
}
#[cfg(unix)]
async fn run_restart_daemon(socket: Option<PathBuf>) -> Result<()> {
let socket = socket.unwrap_or_else(default_socket_path);
let binary = configured_daemon_binary()?;
let hello = restart_control_daemon_at(&socket, &binary).await?;
writeln!(
std::io::stdout().lock(),
"restarted winxd pid={} epoch={}",
hello.daemon_pid,
hello.daemon_epoch
)?;
Ok(())
}
async fn run_doctor() -> Result<()> {
let runtime = winx_code_agent::runtime::configured_runtime_mode().map_or_else(
|error| format!("invalid: {error}"),
|mode| format!("{mode:?}").to_ascii_lowercase(),
);
let mut report = serde_json::json!({
"version": env!("CARGO_PKG_VERSION"),
"platform": {
"os": std::env::consts::OS,
"arch": std::env::consts::ARCH
},
"cwd": std::env::current_dir().ok().map(|path| path.display().to_string()),
"runtime": runtime,
"environment": {
"sandbox_requested": winx_code_agent::config::env_flag("WINX_SANDBOX"),
"redaction_disabled": winx_code_agent::config::env_flag("WINX_NO_REDACT"),
"compression_disabled": winx_code_agent::config::env_flag("WINX_NO_COMPRESS"),
"http_token_configured": winx_code_agent::config::env_text("WINX_HTTP_TOKEN").is_some()
}
});
#[cfg(unix)]
{
let socket = default_socket_path();
let daemon = match DaemonClient::new(&socket).hello().await {
Ok(hello) => serde_json::json!({
"reachable": true,
"socket": socket.display().to_string(),
"pid": hello.daemon_pid,
"epoch": hello.daemon_epoch,
"protocol": format!("{}.{}", hello.protocol_major, hello.protocol_minor),
"capabilities": hello.capabilities
}),
Err(error) => serde_json::json!({
"reachable": false,
"socket": socket.display().to_string(),
"error": error.to_string()
}),
};
report["daemon"] = daemon;
report["daemon_binary"] = match configured_daemon_binary() {
Ok(path) => serde_json::json!({"available": true, "path": path.display().to_string()}),
Err(error) => serde_json::json!({"available": false, "error": error.to_string()}),
};
}
serde_json::to_writer_pretty(std::io::stdout().lock(), &report)
.map_err(|error| WinxError::SerializationError(error.to_string()))?;
writeln!(std::io::stdout().lock())?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn run_http_server(
bind: String,
token: Option<String>,
token_file: Option<PathBuf>,
principal_config: Option<PathBuf>,
allow_weak_token: bool,
allow_non_loopback: bool,
session_affinity: winx_code_agent::config::HttpSessionAffinity,
max_concurrency: usize,
requests_per_minute: u32,
allowed_hosts: Vec<String>,
allow_query_token: bool,
) -> Result<()> {
if token.is_some() {
tracing::warn!("--token exposes the HTTP secret in process arguments; prefer --token-file");
}
let principals = winx_code_agent::config::load_http_principals(
principal_config.as_deref(),
token,
token_file.as_deref(),
allow_weak_token,
)?;
let options = winx_code_agent::http_server::HttpServerOptions {
bind: bind.clone(),
principals,
extra_hosts: allowed_hosts,
allow_query_token,
allow_non_loopback,
session_affinity,
max_concurrency,
requests_per_minute,
};
tracing::info!("Starting winx remote MCP (HTTP) v{} on {bind}", env!("CARGO_PKG_VERSION"));
winx_code_agent::http_server::start_http_server_with_options(options).await.map_err(|error| {
WinxError::ShellInitializationError(format!("HTTP server failed: {error}"))
})
}
async fn run_server() -> Result<()> {
tracing::info!("Starting winx MCP server v{}", env!("CARGO_PKG_VERSION"));
match start_winx_server().await {
Ok(()) => {
tracing::info!("Server shutting down normally");
Ok(())
}
Err(e) => {
tracing::error!("Server error: {}", e);
Err(WinxError::ShellInitializationError(format!("Failed to start server: {e}")))
}
}
}