mod agent;
mod daemon_client;
mod doctor;
mod github;
mod project;
mod prompts;
mod shell;
mod workstream;
use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::Shell;
use serde_json::json;
use vex_config::ConnectionEntry;
#[derive(Parser)]
#[command(
name = "vex",
about = "Parallel workstream manager for software developers"
)]
#[command(version)]
struct Cli {
#[arg(long, global = true)]
host: Option<String>,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Doctor,
Project {
#[command(subcommand)]
action: ProjectAction,
},
Ws {
#[command(subcommand)]
action: WsAction,
},
Shell {
#[command(subcommand)]
action: ShellAction,
},
Agent {
#[command(subcommand)]
action: AgentAction,
},
Daemon {
#[command(subcommand)]
action: DaemonAction,
},
Hub {
#[command(subcommand)]
action: HubAction,
},
Gh {
#[command(subcommand)]
action: GhAction,
},
Prompt {
#[command(subcommand)]
action: PromptAction,
},
Completions {
shell: Shell,
},
}
#[derive(Subcommand)]
enum ProjectAction {
Add {
path: String,
},
List,
Remove {
name: String,
},
}
#[derive(Subcommand)]
enum WsAction {
Create {
project: String,
name: Option<String>,
#[arg(long)]
branch: Option<String>,
#[arg(long)]
pr: Option<String>,
},
List {
project: String,
},
Rename {
project: String,
name: String,
new_name: String,
},
Delete {
project: String,
name: String,
},
Notes {
#[command(subcommand)]
action: WsNotesAction,
},
}
#[derive(Subcommand)]
enum WsNotesAction {
Get {
project: String,
ws: String,
},
Set {
project: String,
ws: String,
content: String,
},
}
#[derive(Subcommand)]
enum ShellAction {
Open {
project: String,
ws: Option<String>,
},
Attach {
shell_id: String,
},
List,
Kill {
shell_id: String,
},
}
#[derive(Subcommand)]
enum AgentAction {
Spawn {
project: String,
ws: Option<String>,
#[arg(long, value_name = "TYPE")]
r#type: Option<String>,
},
Prompt {
agent_id: String,
text: String,
},
List {
project: Option<String>,
ws: Option<String>,
},
Kill {
agent_id: String,
},
AttachShell {
agent_id: String,
},
Conversation {
agent_id: String,
#[arg(long)]
watch: bool,
#[arg(long, short)]
verbose: bool,
},
Status {
agent_id: String,
},
}
#[derive(Subcommand)]
enum DaemonAction {
Start {
#[arg(long)]
foreground: bool,
},
Stop,
Logs,
}
#[derive(Subcommand)]
enum HubAction {
Connect {
host: String,
#[arg(long, default_value = "9800")]
port: u16,
},
Start {
#[arg(long)]
foreground: bool,
},
Stop,
Logs,
}
#[derive(Subcommand)]
enum GhAction {
DetectPr {
project: String,
#[arg(long)]
ws: String,
},
LinkPr {
project: String,
#[arg(long)]
ws: String,
pr_number: u64,
},
UnlinkPr {
project: String,
#[arg(long)]
ws: String,
},
LinkIssue {
project: String,
#[arg(long)]
ws: String,
issue_number: u64,
},
UnlinkIssue {
project: String,
#[arg(long)]
ws: String,
issue_number: u64,
},
Status {
project: String,
#[arg(long)]
ws: String,
},
}
#[derive(Subcommand)]
enum PromptAction {
List,
Create {
name: String,
prompt: String,
},
Update {
id: String,
#[arg(long)]
name: Option<String>,
#[arg(long)]
prompt: Option<String>,
},
Delete {
id: String,
},
}
fn main() {
let cli = Cli::parse();
let host = cli.host.as_deref();
match cli.command {
Commands::Doctor => doctor::run(),
Commands::Project { action } => match action {
ProjectAction::Add { path } => project::add(&path, host),
ProjectAction::List => project::list(host),
ProjectAction::Remove { name } => project::remove(&name, host),
},
Commands::Ws { action } => match action {
WsAction::Create {
project,
name,
branch,
pr,
} => {
workstream::create(
&project,
name.as_deref(),
branch.as_deref(),
pr.as_deref(),
host,
);
}
WsAction::Rename {
project,
name,
new_name,
} => {
workstream::rename(&project, &name, &new_name, host);
}
WsAction::List { project } => workstream::list(&project, host),
WsAction::Delete { project, name } => workstream::delete(&project, &name, host),
WsAction::Notes { action } => match action {
WsNotesAction::Get { project, ws } => workstream::notes_get(&project, &ws, host),
WsNotesAction::Set {
project,
ws,
content,
} => workstream::notes_set(&project, &ws, &content, host),
},
},
Commands::Shell { action } => match action {
ShellAction::Open { project, ws } => shell::open(&project, ws.as_deref(), host),
ShellAction::Attach { shell_id } => shell::attach(&shell_id, host),
ShellAction::List => shell::list(host),
ShellAction::Kill { shell_id } => shell::kill(&shell_id, host),
},
Commands::Agent { action } => match action {
AgentAction::Spawn {
project,
ws,
r#type,
} => agent::spawn(&project, ws.as_deref(), r#type.as_deref(), host),
AgentAction::Prompt { agent_id, text } => agent::prompt(&agent_id, &text, host),
AgentAction::Kill { agent_id } => agent::kill(&agent_id, host),
AgentAction::AttachShell { agent_id } => agent::attach_shell(&agent_id, host),
AgentAction::Conversation {
agent_id,
watch,
verbose,
} => {
agent::conversation(&agent_id, watch, verbose, host);
}
AgentAction::List { project, ws } => {
agent::list(project.as_deref(), ws.as_deref(), host);
}
AgentAction::Status { agent_id } => agent::status(&agent_id, host),
},
Commands::Daemon { action } => match action {
DaemonAction::Start { foreground } => {
daemon_start(foreground);
}
DaemonAction::Stop => {
daemon_stop();
}
DaemonAction::Logs => {
daemon_logs();
}
},
Commands::Gh { action } => match action {
GhAction::DetectPr { project, ws } => github::detect_pr(&project, &ws, host),
GhAction::LinkPr {
project,
ws,
pr_number,
} => github::link_pr(&project, &ws, pr_number, host),
GhAction::UnlinkPr { project, ws } => github::unlink_pr(&project, &ws, host),
GhAction::LinkIssue {
project,
ws,
issue_number,
} => github::link_issue(&project, &ws, issue_number, host),
GhAction::UnlinkIssue {
project,
ws,
issue_number,
} => github::unlink_issue(&project, &ws, issue_number, host),
GhAction::Status { project, ws } => github::status(&project, &ws, host),
},
Commands::Prompt { action } => match action {
PromptAction::List => prompts::list(host),
PromptAction::Create { name, prompt } => prompts::create(&name, &prompt, host),
PromptAction::Update { id, name, prompt } => {
prompts::update(&id, name.as_deref(), prompt.as_deref(), host);
}
PromptAction::Delete { id } => prompts::delete(&id, host),
},
Commands::Completions { shell } => {
clap_complete::generate(shell, &mut Cli::command(), "vex", &mut std::io::stdout());
}
Commands::Hub { action } => match action {
HubAction::Connect { host, port } => {
hub_connect(&host, port);
}
HubAction::Start { foreground } => {
hub_start(foreground);
}
HubAction::Stop => {
hub_stop();
}
HubAction::Logs => {
hub_logs();
}
},
}
}
fn load_vex_home() -> vex_app::VexHome {
match vex_app::VexHome::new(None) {
Ok(h) => h,
Err(e) => {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
}
fn daemon_start(foreground: bool) {
let vex_home = load_vex_home();
let config = vex_home.load_config().unwrap_or_default();
let port = config.daemon_port;
if std::net::TcpStream::connect_timeout(
&std::net::SocketAddr::from(([127, 0, 0, 1], port)),
std::time::Duration::from_millis(200),
)
.is_ok()
{
eprintln!("Daemon is already running on port {port}");
std::process::exit(1);
}
if foreground {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
if let Err(e) = vex_daemon::run(vex_home).await {
eprintln!("Daemon error: {e}");
std::process::exit(1);
}
});
} else {
let exe = match std::env::current_exe() {
Ok(e) => e,
Err(e) => {
eprintln!("Error finding executable: {e}");
std::process::exit(1);
}
};
let log_path = vex_home.root().join("daemon.log");
let log_file = match std::fs::File::create(&log_path) {
Ok(f) => f,
Err(e) => {
eprintln!("Error creating daemon log: {e}");
std::process::exit(1);
}
};
let log_file2 = log_file.try_clone().unwrap();
match std::process::Command::new(exe)
.args(["daemon", "start", "--foreground"])
.stdout(std::process::Stdio::from(log_file))
.stderr(std::process::Stdio::from(log_file2))
.spawn()
{
Ok(child) => {
std::thread::sleep(std::time::Duration::from_millis(500));
println!("Daemon started (PID: {})", child.id());
}
Err(e) => {
eprintln!("Error starting daemon: {e}");
std::process::exit(1);
}
}
}
}
fn daemon_logs() {
let vex_home = load_vex_home();
let log_path = vex_home.root().join("daemon.log");
if !log_path.exists() {
eprintln!("No daemon log file found. Start the daemon first.");
std::process::exit(1);
}
let _ = std::process::Command::new("tail")
.args(["-f", &log_path.to_string_lossy()])
.status();
}
fn daemon_stop() {
let vex_home = load_vex_home();
let token = vex_home.load_or_create_token().ok();
let config = vex_home.load_config().unwrap_or_default();
let url = format!("ws://127.0.0.1:{}/ws", config.daemon_port);
if let Some(token) = token
&& let Ok(mut client) = daemon_client::DaemonClient::connect(&url, &token)
{
match client.request("daemon.shutdown", json!({})) {
Ok(_) => {
println!("Daemon stopped");
return;
}
Err(e) => {
eprintln!("Warning: shutdown RPC failed: {e}");
}
}
}
let pid_path = vex_home.root().join("daemon.pid");
if pid_path.exists()
&& let Ok(pid_str) = std::fs::read_to_string(&pid_path)
&& let Ok(pid) = pid_str.trim().parse::<i32>()
{
#[cfg(unix)]
unsafe {
libc::kill(pid, libc::SIGTERM);
}
let _ = std::fs::remove_file(&pid_path);
println!("Daemon stopped (via PID)");
return;
}
eprintln!("Could not stop daemon: not running or unreachable");
std::process::exit(1);
}
fn hub_connect(host: &str, port: u16) {
let vex_home = load_vex_home();
let is_local = host == "local" || host == "localhost" || host == "127.0.0.1";
let is_ssh = !is_local && vex_app::ssh::is_ssh_host(host);
let (url, ssh_host, remote_port) = if is_ssh {
let local_port = vex_app::ssh::find_free_port();
eprintln!("Setting up SSH tunnel to {host} ({local_port} -> localhost:{port})...");
if let Err(e) = vex_app::ssh::setup_tunnel(host, local_port, port) {
eprintln!("Error: {e}");
std::process::exit(1);
}
(
format!("ws://127.0.0.1:{local_port}/ws"),
Some(host.to_string()),
Some(port),
)
} else {
let resolved_host = if host == "local" { "127.0.0.1" } else { host };
(format!("ws://{resolved_host}:{port}/ws"), None, None)
};
let token = if is_local {
match vex_home.load_or_create_token() {
Ok(t) => t,
Err(e) => {
eprintln!("Error reading local token: {e}");
std::process::exit(1);
}
}
} else {
prompt_token()
};
match daemon_client::DaemonClient::connect(&url, &token) {
Ok(_) => {}
Err(e) => {
eprintln!("Error connecting to daemon at {url}: {e}");
std::process::exit(1);
}
}
let mut connections = vex_home.load_connections().unwrap_or_default();
connections.insert(
host.to_string(),
ConnectionEntry {
url: url.clone(),
token,
ssh_host,
remote_port,
},
);
if let Err(e) = vex_home.save_connections(&connections) {
eprintln!("Error saving connections: {e}");
std::process::exit(1);
}
if is_ssh {
println!("Connected to daemon at {host} via SSH tunnel ({url})");
} else {
println!("Connected to daemon at {url}");
}
}
fn prompt_token() -> String {
eprint!("Enter daemon token: ");
std::io::Write::flush(&mut std::io::stderr()).expect("failed to flush stderr");
let mut token = String::new();
std::io::stdin()
.read_line(&mut token)
.expect("failed to read token");
let token = token.trim().to_string();
if token.is_empty() {
eprintln!("Token cannot be empty");
std::process::exit(1);
}
token
}
fn hub_start(foreground: bool) {
let vex_home = load_vex_home();
let hub_config = vex_home.load_hub_config().unwrap_or_default();
let port = hub_config.web_port;
if std::net::TcpStream::connect_timeout(
&std::net::SocketAddr::from(([127, 0, 0, 1], port)),
std::time::Duration::from_millis(200),
)
.is_ok()
{
eprintln!("Hub is already running on port {port}");
std::process::exit(1);
}
if foreground {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
if let Err(e) = vex_hub::run(vex_home).await {
eprintln!("Hub error: {e}");
std::process::exit(1);
}
});
} else {
let exe = match std::env::current_exe() {
Ok(e) => e,
Err(e) => {
eprintln!("Error finding executable: {e}");
std::process::exit(1);
}
};
let log_path = vex_home.root().join("hub.log");
let log_file = match std::fs::File::create(&log_path) {
Ok(f) => f,
Err(e) => {
eprintln!("Error creating hub log: {e}");
std::process::exit(1);
}
};
let log_file2 = log_file.try_clone().unwrap();
match std::process::Command::new(exe)
.args(["hub", "start", "--foreground"])
.stdout(std::process::Stdio::from(log_file))
.stderr(std::process::Stdio::from(log_file2))
.spawn()
{
Ok(child) => {
std::thread::sleep(std::time::Duration::from_millis(500));
println!("Hub started (PID: {})", child.id());
println!("Web UI available at http://localhost:{port}");
}
Err(e) => {
eprintln!("Error starting hub: {e}");
std::process::exit(1);
}
}
}
}
fn hub_stop() {
let vex_home = load_vex_home();
let pid_path = vex_home.root().join("hub.pid");
if !pid_path.exists() {
eprintln!("Hub is not running (no PID file)");
std::process::exit(1);
}
let pid_str = match std::fs::read_to_string(&pid_path) {
Ok(s) => s.trim().to_string(),
Err(e) => {
eprintln!("Error reading hub PID: {e}");
std::process::exit(1);
}
};
let pid: i32 = match pid_str.parse() {
Ok(p) => p,
Err(e) => {
eprintln!("Invalid hub PID: {e}");
std::process::exit(1);
}
};
#[cfg(unix)]
{
unsafe {
libc::kill(pid, libc::SIGTERM);
}
}
let _ = std::fs::remove_file(&pid_path);
println!("Hub stopped");
}
fn hub_logs() {
let vex_home = load_vex_home();
let log_path = vex_home.root().join("hub.log");
if !log_path.exists() {
eprintln!("No hub log file found. Start the hub first.");
std::process::exit(1);
}
let _ = std::process::Command::new("tail")
.args(["-f", &log_path.to_string_lossy()])
.status();
}