vibe-kanban-cli 0.1.4

Interactive CLI for Vibe Kanban
Documentation
//! Vibe Kanban CLI - Terminal-first, real-time task viewer and creator.

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<()> {
    // Install rustls crypto provider before any TLS operations
    rustls::crypto::aws_lc_rs::default_provider()
        .install_default()
        .expect("Failed to install rustls crypto provider");

    let args = Args::parse();

    // Initialize logging
    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)
}