mod cli_args;
mod render;
mod resolve;
mod utils;
mod watch;
use anyhow::{Context, Result, anyhow};
use clap::Parser;
use serde_json;
use vibe_kanban_cli::{
VibeKanbanClient,
types::{CreateAndStartTaskRequest, CreateProject, CreateProjectRepo, CreateTask, ExecutorProfileId},
};
use crate::{
cli_args::{Args, Command, ProjectCommand, ServerCommand},
resolve::{parse_uuid, resolve_project, resolve_repo_inputs},
utils::{truncate_title},
watch::{WatchFilter, watch_tasks},
};
#[tokio::main]
async fn main() -> Result<()> {
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
let args = Args::parse();
if args.debug {
tracing_subscriber::fmt().with_env_filter("debug").init();
}
let client = VibeKanbanClient::new(&args.server).context("Failed to create API client")?;
match args.command {
Command::Create {
project,
prompt,
title,
status,
tool,
model,
repos,
branch,
watch,
} => {
let project = resolve_project(&client, &project).await?;
let executor = parse_executor(&tool)?;
let status = parse_status(&status)?;
let repo_inputs =
resolve_repo_inputs(&client, project.id, repos, branch.as_deref()).await?;
let task_title = title.unwrap_or_else(|| truncate_title(&prompt));
let task = CreateTask {
project_id: project.id,
title: task_title,
description: Some(prompt),
status: Some(status),
parent_workspace_id: None,
image_ids: None,
is_epic: None,
complexity: None,
metadata: None,
};
let executor_profile_id = ExecutorProfileId {
executor,
variant: model,
};
let request = CreateAndStartTaskRequest {
task,
executor_profile_id,
repos: repo_inputs,
};
let created = client.create_and_start_task(&request).await?;
let project_name = project.name.clone();
println!(
"Created task {} in project {}",
created.task.id, project_name
);
if watch {
watch_tasks(
&client,
&args.server,
WatchFilter::TaskId(created.task.id),
Some(project),
)
.await?;
}
}
Command::Watch { project, task, slug } => {
let filter = match (task, slug) {
(Some(task_id), None) => WatchFilter::TaskId(parse_uuid(&task_id)?),
(None, Some(slug)) => WatchFilter::Slug(slug),
(None, None) => WatchFilter::None,
_ => {
return Err(anyhow!(
"Use only one of --task or --slug when watching"
));
}
};
let project = match (&filter, project) {
(WatchFilter::TaskId(task_id), _) => {
let task = client.get_task(*task_id).await?;
let project = client.get_project(task.project_id).await?;
Some(project)
}
(WatchFilter::Slug(_), Some(project_ref))
| (WatchFilter::None, Some(project_ref)) => {
Some(resolve_project(&client, &project_ref).await?)
}
_ => None,
};
if matches!(filter, WatchFilter::Slug(_) | WatchFilter::None) && project.is_none() {
return Err(anyhow!(
"--project is required when watching by slug or showing the board"
));
}
watch_tasks(&client, &args.server, filter, project).await?;
}
Command::Projects { json } => {
let projects = client.list_projects().await?;
if json {
println!("{}", serde_json::to_string_pretty(&projects)?);
} else if projects.is_empty() {
println!("No projects found.");
} else {
println!("Projects:");
for project in projects {
println!(" {} {}", project.id, project.name);
}
}
}
Command::Project { command } => match command {
ProjectCommand::Add {
path,
name,
display_name,
} => {
let repo_path = std::fs::canonicalize(&path)
.with_context(|| format!("Invalid path: {}", path))?;
let repo_path_str = repo_path
.to_str()
.ok_or_else(|| anyhow!("Path contains invalid UTF-8"))?
.to_string();
let folder_name = repo_path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| anyhow!("Unable to determine folder name for {}", repo_path_str))?
.to_string();
let project_name = name.unwrap_or(folder_name.clone());
let repo_display_name = display_name.unwrap_or(project_name.clone());
let payload = CreateProject {
name: project_name,
repositories: vec![CreateProjectRepo {
display_name: repo_display_name,
git_repo_path: repo_path_str,
}],
};
let created = client.create_project(&payload).await?;
println!("Created project {} ({})", created.name, created.id);
}
},
Command::Server { command } => match command {
ServerCommand::Start {
command,
background,
port,
log,
} => {
start_server(&command, background, port, &log)?;
}
},
}
Ok(())
}
fn start_server(
command: &str,
background: bool,
port: Option<u16>,
log_path: &str,
) -> Result<()> {
use std::fs::OpenOptions;
use std::process::{Command, Stdio};
let resolved_port = port.or_else(|| {
std::env::var("SERVER_PORT")
.ok()
.and_then(|value| value.parse::<u16>().ok())
});
if background {
let log_file = OpenOptions::new()
.create(true)
.append(true)
.open(log_path)
.with_context(|| format!("Failed to open log file {}", log_path))?;
let mut cmd = Command::new("bash");
cmd.arg("-lc").arg(command);
if let Some(port) = resolved_port {
cmd.env("SERVER_PORT", port.to_string());
cmd.env("BACKEND_PORT", port.to_string());
}
let mut child = cmd
.stdin(Stdio::null())
.stdout(log_file.try_clone()?)
.stderr(log_file)
.spawn()
.with_context(|| format!("Failed to start server with '{}'", command))?;
println!(
"Server started in background (pid: {}). Logs: {}",
child.id(),
log_path
);
Ok(())
} else {
let mut cmd = Command::new("bash");
cmd.arg("-lc").arg(command);
if let Some(port) = resolved_port {
cmd.env("SERVER_PORT", port.to_string());
cmd.env("BACKEND_PORT", port.to_string());
}
let status = cmd
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.with_context(|| format!("Failed to start server with '{}'", command))?;
if !status.success() {
return Err(anyhow!("Server exited with status: {}", status));
}
Ok(())
}
}
fn parse_executor(input: &str) -> Result<vibe_kanban_cli::types::BaseCodingAgent> {
let normalized = input.trim().to_lowercase();
let executor = match normalized.as_str() {
"claude" | "claude-code" | "claude_code" => vibe_kanban_cli::types::BaseCodingAgent::ClaudeCode,
"amp" => vibe_kanban_cli::types::BaseCodingAgent::Amp,
"gemini" => vibe_kanban_cli::types::BaseCodingAgent::Gemini,
"codex" => vibe_kanban_cli::types::BaseCodingAgent::Codex,
"opencode" | "open-code" | "open_code" => vibe_kanban_cli::types::BaseCodingAgent::Opencode,
"cursor" | "cursor-agent" | "cursor_agent" => vibe_kanban_cli::types::BaseCodingAgent::CursorAgent,
"qwen" | "qwen-code" | "qwen_code" => vibe_kanban_cli::types::BaseCodingAgent::QwenCode,
"copilot" => vibe_kanban_cli::types::BaseCodingAgent::Copilot,
"droid" => vibe_kanban_cli::types::BaseCodingAgent::Droid,
_ => {
return Err(anyhow!(
"Unknown tool '{}'. Try codex, claude-code, cursor, gemini, opencode, qwen-code, amp, copilot, droid.",
input
))
}
};
Ok(executor)
}
fn parse_status(input: &str) -> Result<vibe_kanban_cli::types::TaskStatus> {
let normalized = input.trim().to_lowercase();
let status = match normalized.as_str() {
"todo" => vibe_kanban_cli::types::TaskStatus::Todo,
"inprogress" | "in-progress" => vibe_kanban_cli::types::TaskStatus::Inprogress,
"inreview" | "in-review" => vibe_kanban_cli::types::TaskStatus::Inreview,
"done" => vibe_kanban_cli::types::TaskStatus::Done,
"cancelled" | "canceled" => vibe_kanban_cli::types::TaskStatus::Cancelled,
_ => {
return Err(anyhow!(
"Unknown status '{}'. Try todo, inprogress, inreview, done, cancelled.",
input
))
}
};
Ok(status)
}