tnk 0.1.47

Zero-trust sandbox for local inference and secure AI coding agent runtimes.
pub mod catalog;
pub mod config;
pub mod doctor;
pub mod init;
pub mod lifecycle;
pub mod sandbox;
pub mod shutdown;
pub mod ui;

use clap::{ArgAction, CommandFactory, Parser, Subcommand, ValueEnum};
use clap_complete::{Shell, generate};
use serde::Serialize;

use crate::config::ConfigCommands;

#[derive(Parser)]
#[command(
    name = "tnk",
    version,
    author,
    about = "Per-project sandbox VMs for AI agent runtimes",
    long_about = "Zero-trust per-project sandbox VMs for AI agent runtimes.",
    arg_required_else_help = false,
    propagate_version = true,
    trailing_var_arg = true
)]
struct Cli {
    #[arg(short, long, global = true, help = "quiet")]
    quiet: bool,

    #[arg(short, long, global = true, help = "verbose")]
    verbose: bool,

    #[command(subcommand)]
    command: Option<Commands>,
}

#[derive(ValueEnum, Clone, Copy, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum OutputFormat {
    Text,
    Json,
    Ndjson,
}

#[derive(Subcommand)]
enum Commands {
    #[command(about = "project sandboxes")]
    Sandbox {
        #[command(subcommand)]
        action: SandboxCommands,
    },

    #[command(about = "start project sandbox")]
    Run {
        #[arg(long, help = "profile")]
        profile: Option<String>,
        #[arg(long, help = "audit log file")]
        audit_log: Option<String>,
        #[arg(long, alias = "enter", help = "shell")]
        shell: bool,
        #[arg(short = 'n', long, help = "dry-run")]
        dry_run: bool,
    },

    #[command(about = "shutdown")]
    Shutdown {
        #[arg(long, value_name = "SECONDS", help = "timeout")]
        timeout: Option<u64>,
        #[arg(short = 'n', long, help = "dry-run")]
        dry_run: bool,
    },

    #[command(about = "shell completions")]
    Completion {
        #[arg(value_enum, help = "shell")]
        shell: Shell,
    },

    #[command(about = "init")]
    Init {
        #[arg(long, help = "git url")]
        git_url: Option<String>,
        #[arg(long, help = "force")]
        force: bool,
    },

    #[command(about = "config")]
    Config {
        #[command(subcommand)]
        action: ConfigCommands,
    },

    #[command(about = "diagnostics")]
    Doctor,
}

#[derive(Subcommand)]
pub enum SandboxCommands {
    #[command(about = "start")]
    Start {
        #[arg(long, help = "profile")]
        profile: Option<String>,
        #[arg(long, help = "audit log file")]
        audit_log: Option<String>,
        #[arg(long, alias = "enter", help = "shell")]
        shell: bool,
    },
    #[command(about = "shell")]
    Shell {
        #[arg(long, help = "profile")]
        profile: Option<String>,
        #[arg(short, long, help = "command")]
        command: Option<String>,
        #[arg(long, help = "no-tty")]
        no_tty: bool,
        #[arg(short, long, action = ArgAction::Append, help = "env")]
        env: Vec<String>,
        #[arg(long, help = "audit log file")]
        audit_log: Option<String>,
    },
    #[command(about = "stop")]
    Stop {
        #[arg(long, help = "all")]
        all: bool,
        #[arg(long, action = ArgAction::Append, help = "name")]
        name: Vec<String>,
    },
    #[command(about = "delete")]
    Delete {
        #[arg(short, long, help = "yes")]
        yes: bool,
        #[arg(short = 'n', long, help = "dry-run")]
        dry_run: bool,
    },
    #[command(about = "list")]
    Ls {
        #[arg(short, long, value_enum, default_value_t = OutputFormat::Text)]
        output: OutputFormat,
        #[arg(short, long, help = "quiet")]
        quiet: bool,
    },
}

#[tokio::main]
async fn main() {
    color_eyre::install().unwrap();
    if let Err(err) = run().await {
        let code = crate::ui::exit_code_for_error(&err);
        let msg = err.to_string();
        if code == crate::ui::ExitCode::Error {
            eprintln!("{err}");
        } else {
            eprintln!("error: {}", msg.lines().next().unwrap_or(&msg));
        }
        std::process::exit(code.as_i32());
    }
}

async fn start_project_sandbox(
    profile: Option<String>,
    audit_log: Option<String>,
    shell: bool,
) -> Result<(), color_eyre::Report> {
    let home = std::env::var("HOME")?;
    let config_dir = std::path::PathBuf::from(&home).join(".config/tnk");
    let cfg = config::load().await?;
    let default_profile = cfg
        .default_provision_profile
        .unwrap_or_else(|| "pi".to_string());

    let profiles = catalog::list_profiles(&config_dir).await?;
    let all_profiles: Vec<String> = std::iter::once("base".to_string())
        .chain(profiles.iter().map(|p| p.name.clone()))
        .collect();

    let (sandbox_id, _, _) = sandbox::resolve_workspace_context().await?;
    let sandbox_exists = if sandbox_id.is_empty() {
        false
    } else {
        sandbox::sandbox_exists(&sandbox_id).await?
    };

    let profile_name = match profile {
        Some(p) => p,
        None if all_profiles.iter().any(|name| name == &default_profile) => default_profile,
        None => {
            if sandbox_exists {
                "base".to_string()
            } else {
                eprintln!(
                    "warning: default profile '{}' not found, using 'base'",
                    default_profile
                );
                "base".to_string()
            }
        }
    };

    sandbox::start(profile_name, audit_log.clone()).await?;

    if shell {
        use std::io::IsTerminal;
        if !std::io::stdin().is_terminal()
            || !std::io::stdout().is_terminal()
            || !std::io::stderr().is_terminal()
        {
            ui::exit_with(
                ui::ExitCode::Usage,
                "--shell requires an interactive terminal",
            );
        }

        sandbox::shell(None, None, false, Vec::new(), audit_log).await?;
    }

    Ok(())
}

async fn run() -> Result<(), color_eyre::Report> {
    let cli = match Cli::try_parse() {
        Ok(cli) => cli,
        Err(e) => {
            let exit_code = crate::ui::clap_exit_code_for_kind(&e.kind());
            if exit_code != 0 {
                eprintln!("error: {}", e);
            } else {
                eprintln!("{}", e);
            }
            std::process::exit(exit_code);
        }
    };

    if cli.quiet {
        crate::ui::set_quiet();
    } else if cli.verbose {
        crate::ui::set_verbose();
    }

    match cli.command {
        None => {
            sandbox::ls(OutputFormat::Text, false).await?;
        }
        Some(Commands::Sandbox { action }) => match action {
            SandboxCommands::Start {
                profile,
                audit_log,
                shell,
            } => start_project_sandbox(profile, audit_log, shell).await?,
            SandboxCommands::Shell {
                profile,
                command,
                no_tty,
                env,
                audit_log,
            } => sandbox::shell(profile, command, no_tty, env, audit_log).await?,
            SandboxCommands::Stop { all, name } => {
                if all && !name.is_empty() {
                    ui::exit_with(ui::ExitCode::Usage, "--all cannot be combined with --name");
                }
                sandbox::stop(name, all).await?
            }
            SandboxCommands::Delete { yes, dry_run } => {
                if dry_run {
                    crate::ui::log_info("dry run, skipping sandbox deletion");
                    return Ok(());
                }
                let (sandbox_id, _, _) = sandbox::resolve_workspace_context().await?;
                if sandbox_id.is_empty() || sandbox_id == "tnk-config" {
                    ui::exit_with(ui::ExitCode::Usage, "must be inside a project directory");
                }
                sandbox::delete_sandbox(&sandbox_id, yes).await?
            }
            SandboxCommands::Ls { output, quiet } => sandbox::ls(output, quiet).await?,
        },
        Some(Commands::Run {
            profile,
            audit_log,
            shell,
            dry_run,
        }) => {
            if dry_run {
                crate::ui::log_info("dry run, skipping sandbox start");
                return Ok(());
            }
            sandbox::cleanup_untracked_vms(crate::ui::is_verbose()).await?;
            start_project_sandbox(profile, audit_log, shell).await?
        }
        Some(Commands::Shutdown { timeout, dry_run }) => {
            shutdown::run(timeout, dry_run).await?;
        }
        Some(Commands::Init { git_url, force }) => {
            match tokio::time::timeout(
                std::time::Duration::from_secs(120),
                tokio::task::spawn_blocking(move || {
                    init::run(init::InitCommands { git_url, force })
                }),
            )
            .await
            {
                Ok(Ok(Ok(()))) => {}
                Ok(Ok(Err(err))) => return Err(err),
                Ok(Err(join_err)) => {
                    return Err(color_eyre::eyre::eyre!("init task panicked: {}", join_err));
                }
                Err(_) => {
                    return Err(color_eyre::eyre::eyre!(
                        "init timed out after 120s; check network connectivity"
                    ));
                }
            }
        }
        Some(Commands::Config { action }) => match action {
            ConfigCommands::Init { force } => config::init_config(force)?,
            ConfigCommands::Show => {
                let cfg = config::load().await?;
                cfg.print_resolved();
            }
        },
        Some(Commands::Doctor) => {
            doctor::run().await?;
        }
        Some(Commands::Completion { shell }) => {
            let mut cmd = Cli::command();
            generate(shell, &mut cmd, "tnk", &mut std::io::stdout());
        }
    }

    Ok(())
}