rewrite planoai CLI in Rust

Add crates/plano-cli/ as a full Rust rewrite of the Python CLI.
Binary name: planoai. All subcommands ported: up, down, build,
logs, cli-agent, trace, init. Config validation and Tera template
rendering replace the Python config_generator. CI updated to use
cargo test/build instead of Python test jobs.
This commit is contained in:
Adil Hafeez 2026-03-22 22:57:35 +00:00
parent 406fa92802
commit 15b9e8b95c
37 changed files with 4658 additions and 91 deletions

View file

@ -0,0 +1,105 @@
use std::process::Command;
use anyhow::{bail, Result};
use crate::consts::plano_docker_image;
use crate::utils::{find_repo_root, print_cli_header};
pub async fn run(docker: bool) -> Result<()> {
let dim = console::Style::new().dim();
let red = console::Style::new().red();
let bold = console::Style::new().bold();
if !docker {
print_cli_header();
let repo_root = find_repo_root().ok_or_else(|| {
anyhow::anyhow!(
"Could not find repository root. Make sure you're inside the plano repository."
)
})?;
let crates_dir = repo_root.join("crates");
// Check cargo is available
if which::which("cargo").is_err() {
eprintln!(
"{} {} not found. Install Rust: https://rustup.rs",
red.apply_to(""),
bold.apply_to("cargo")
);
std::process::exit(1);
}
// Build WASM plugins
eprintln!(
"{}",
dim.apply_to("Building WASM plugins (wasm32-wasip1)...")
);
let status = Command::new("cargo")
.args([
"build",
"--release",
"--target",
"wasm32-wasip1",
"-p",
"llm_gateway",
"-p",
"prompt_gateway",
])
.current_dir(&crates_dir)
.status()?;
if !status.success() {
bail!("WASM build failed");
}
// Build brightstaff
eprintln!("{}", dim.apply_to("Building brightstaff (native)..."));
let status = Command::new("cargo")
.args(["build", "--release", "-p", "brightstaff"])
.current_dir(&crates_dir)
.status()?;
if !status.success() {
bail!("brightstaff build failed");
}
let wasm_dir = crates_dir.join("target/wasm32-wasip1/release");
let native_dir = crates_dir.join("target/release");
println!("\n{}:", bold.apply_to("Build artifacts"));
println!(" {}", wasm_dir.join("prompt_gateway.wasm").display());
println!(" {}", wasm_dir.join("llm_gateway.wasm").display());
println!(" {}", native_dir.join("brightstaff").display());
} else {
let repo_root =
find_repo_root().ok_or_else(|| anyhow::anyhow!("Could not find repository root."))?;
let dockerfile = repo_root.join("Dockerfile");
if !dockerfile.exists() {
bail!("Dockerfile not found at {}", dockerfile.display());
}
println!("Building plano image from {}...", repo_root.display());
let status = Command::new("docker")
.args([
"build",
"-f",
&dockerfile.to_string_lossy(),
"-t",
&plano_docker_image(),
&repo_root.to_string_lossy(),
"--add-host=host.docker.internal:host-gateway",
])
.status()?;
if !status.success() {
bail!("Docker build failed");
}
println!("plano image built successfully.");
}
Ok(())
}

View file

@ -0,0 +1,103 @@
use std::collections::HashMap;
use std::process::Command;
use anyhow::{bail, Result};
use crate::consts::PLANO_DOCKER_NAME;
use crate::utils::{find_config_file, is_native_plano_running};
pub async fn run(agent_type: &str, file: Option<String>, path: &str, settings: &str) -> Result<()> {
let native_running = is_native_plano_running();
let docker_running = if !native_running {
crate::docker::container_status(PLANO_DOCKER_NAME).await? == "running"
} else {
false
};
if !native_running && !docker_running {
bail!("Plano is not running. Start Plano first using 'plano up <config.yaml>' (native or --docker mode).");
}
let plano_config_file = find_config_file(path, file.as_deref());
if !plano_config_file.exists() {
bail!("Config file not found: {}", plano_config_file.display());
}
start_cli_agent(&plano_config_file, agent_type, settings)
}
fn start_cli_agent(
plano_config_path: &std::path::Path,
agent_type: &str,
_settings_json: &str,
) -> Result<()> {
let config_str = std::fs::read_to_string(plano_config_path)?;
let config: serde_yaml::Value = serde_yaml::from_str(&config_str)?;
// Resolve CLI agent endpoint
let (host, port) = resolve_cli_agent_endpoint(&config)?;
let base_url = format!("http://{host}:{port}/v1");
let mut env: HashMap<String, String> = std::env::vars().collect();
match agent_type {
"claude" => {
env.insert("ANTHROPIC_BASE_URL".to_string(), base_url);
// Check for model alias
if let Some(model) = config
.get("model_aliases")
.and_then(|a| a.get("arch"))
.and_then(|a| a.get("claude"))
.and_then(|a| a.get("code"))
.and_then(|a| a.get("small"))
.and_then(|a| a.get("fast"))
.and_then(|a| a.get("target"))
.and_then(|v| v.as_str())
{
env.insert("ANTHROPIC_MODEL".to_string(), model.to_string());
}
let status = Command::new("claude").envs(&env).status()?;
if !status.success() {
std::process::exit(status.code().unwrap_or(1));
}
}
"codex" => {
env.insert("OPENAI_BASE_URL".to_string(), base_url);
let status = Command::new("codex").envs(&env).status()?;
if !status.success() {
std::process::exit(status.code().unwrap_or(1));
}
}
_ => bail!("Unsupported agent type: {agent_type}"),
}
Ok(())
}
fn resolve_cli_agent_endpoint(config: &serde_yaml::Value) -> Result<(String, u16)> {
// Look for model listener (egress_traffic)
if let Some(listeners) = config.get("listeners").and_then(|v| v.as_sequence()) {
for listener in listeners {
let listener_type = listener.get("type").and_then(|v| v.as_str()).unwrap_or("");
if listener_type == "model" {
let host = listener
.get("address")
.and_then(|v| v.as_str())
.unwrap_or("0.0.0.0");
let port = listener
.get("port")
.and_then(|v| v.as_u64())
.unwrap_or(12000) as u16;
return Ok((host.to_string(), port));
}
}
}
// Default
Ok(("0.0.0.0".to_string(), 12000))
}

View file

@ -0,0 +1,15 @@
use anyhow::Result;
use crate::utils::print_cli_header;
pub async fn run(docker: bool) -> Result<()> {
print_cli_header();
if !docker {
crate::native::runner::stop_native()?;
} else {
crate::docker::stop_container().await?;
}
Ok(())
}

View file

@ -0,0 +1,132 @@
use std::path::Path;
use anyhow::{bail, Result};
const TEMPLATES: &[(&str, &str, &str)] = &[
(
"sub_agent_orchestration",
"Sub-agent Orchestration",
include_str!("../../templates/sub_agent_orchestration.yaml"),
),
(
"coding_agent_routing",
"Coding Agent Routing",
include_str!("../../templates/coding_agent_routing.yaml"),
),
(
"preference_aware_routing",
"Preference-Aware Routing",
include_str!("../../templates/preference_aware_routing.yaml"),
),
(
"filter_chain_guardrails",
"Filter Chain Guardrails",
include_str!("../../templates/filter_chain_guardrails.yaml"),
),
(
"conversational_state",
"Conversational State",
include_str!("../../templates/conversational_state.yaml"),
),
];
pub async fn run(
template: Option<String>,
clean: bool,
output: Option<String>,
force: bool,
list_templates: bool,
) -> Result<()> {
let bold = console::Style::new().bold();
let dim = console::Style::new().dim();
let green = console::Style::new().green();
let cyan = console::Style::new().cyan();
if list_templates {
println!("\n{}:", bold.apply_to("Available templates"));
for (id, name, _) in TEMPLATES {
println!(" {} - {}", cyan.apply_to(id), name);
}
println!();
return Ok(());
}
let output_path = output.unwrap_or_else(|| "plano_config.yaml".to_string());
let output_path = Path::new(&output_path);
if output_path.exists() && !force {
bail!(
"File {} already exists. Use --force to overwrite.",
output_path.display()
);
}
if clean {
let content = "version: v0.3.0\nlisteners:\n - type: model\n name: egress_traffic\n port: 12000\nmodel_providers: []\n";
std::fs::write(output_path, content)?;
println!(
"{} Created clean config at {}",
green.apply_to(""),
output_path.display()
);
return Ok(());
}
if let Some(template_id) = template {
let tmpl = TEMPLATES
.iter()
.find(|(id, _, _)| *id == template_id)
.ok_or_else(|| {
anyhow::anyhow!(
"Unknown template '{}'. Use --list-templates to see available templates.",
template_id
)
})?;
std::fs::write(output_path, tmpl.2)?;
println!(
"{} Created config from template '{}' at {}",
green.apply_to(""),
tmpl.1,
output_path.display()
);
// Preview
let lines: Vec<&str> = tmpl.2.lines().take(28).collect();
println!("\n{}:", dim.apply_to("Preview"));
for line in &lines {
println!(" {}", dim.apply_to(line));
}
if tmpl.2.lines().count() > 28 {
println!(" {}", dim.apply_to("..."));
}
return Ok(());
}
// Interactive mode using dialoguer
if !atty::is(atty::Stream::Stdin) {
bail!(
"Interactive mode requires a TTY. Use --template or --clean for non-interactive mode."
);
}
let selections: Vec<&str> = TEMPLATES.iter().map(|(_, name, _)| *name).collect();
let selection = dialoguer::Select::new()
.with_prompt("Choose a template")
.items(&selections)
.default(0)
.interact()?;
let tmpl = &TEMPLATES[selection];
std::fs::write(output_path, tmpl.2)?;
println!(
"\n{} Created config from template '{}' at {}",
green.apply_to(""),
tmpl.1,
output_path.display()
);
Ok(())
}

View file

@ -0,0 +1,10 @@
use anyhow::Result;
pub async fn run(debug: bool, follow: bool, docker: bool) -> Result<()> {
if !docker {
crate::native::runner::native_logs(debug, follow)?;
} else {
crate::docker::stream_logs(debug, follow).await?;
}
Ok(())
}

View file

@ -0,0 +1,262 @@
pub mod build;
pub mod cli_agent;
pub mod down;
pub mod init;
pub mod logs;
pub mod up;
use clap::{Parser, Subcommand};
use crate::consts::PLANO_VERSION;
const LOGO: &str = r#"
______ _
| ___ \ |
| |_/ / | __ _ _ __ ___
| __/| |/ _` | '_ \ / _ \
| | | | (_| | | | | (_) |
\_| |_|\__,_|_| |_|\___/
"#;
#[derive(Parser)]
#[command(
name = "planoai",
about = "The Delivery Infrastructure for Agentic Apps"
)]
#[command(version = PLANO_VERSION)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Command>,
}
#[derive(Subcommand)]
pub enum Command {
/// Start Plano
Up {
/// Config file path (positional)
file: Option<String>,
/// Path to the directory containing config.yaml
#[arg(long, default_value = ".")]
path: String,
/// Run Plano in the foreground
#[arg(long)]
foreground: bool,
/// Start a local OTLP trace collector
#[arg(long)]
with_tracing: bool,
/// Port for the OTLP trace collector
#[arg(long, default_value_t = 4317)]
tracing_port: u16,
/// Run Plano inside Docker instead of natively
#[arg(long)]
docker: bool,
},
/// Stop Plano
Down {
/// Stop a Docker-based Plano instance
#[arg(long)]
docker: bool,
},
/// Build Plano from source
Build {
/// Build the Docker image instead of native binaries
#[arg(long)]
docker: bool,
},
/// Stream logs from Plano
Logs {
/// Show detailed debug logs
#[arg(long)]
debug: bool,
/// Follow the logs
#[arg(long)]
follow: bool,
/// Stream logs from a Docker-based Plano instance
#[arg(long)]
docker: bool,
},
/// Start a CLI agent connected to Plano
CliAgent {
/// The type of CLI agent to start
#[arg(value_parser = ["claude", "codex"])]
agent_type: String,
/// Config file path (positional)
file: Option<String>,
/// Path to the directory containing plano_config.yaml
#[arg(long, default_value = ".")]
path: String,
/// Additional settings as JSON string for the CLI agent
#[arg(long, default_value = "{}")]
settings: String,
},
/// Manage distributed traces
Trace {
#[command(subcommand)]
command: TraceCommand,
},
/// Initialize a new Plano configuration
Init {
/// Use a built-in template
#[arg(long)]
template: Option<String>,
/// Create a clean empty config
#[arg(long)]
clean: bool,
/// Output file path
#[arg(long, short)]
output: Option<String>,
/// Overwrite existing files
#[arg(long)]
force: bool,
/// List available templates
#[arg(long)]
list_templates: bool,
},
}
#[derive(Subcommand)]
pub enum TraceCommand {
/// Start the OTLP trace listener
Listen {
/// Host to bind to
#[arg(long, default_value = "0.0.0.0")]
host: String,
/// Port to listen on
#[arg(long, default_value_t = 4317)]
port: u16,
},
/// Stop the trace listener
Down,
/// Show a specific trace
Show {
/// Trace ID to display
trace_id: String,
/// Show verbose span details
#[arg(long)]
verbose: bool,
},
/// Tail recent traces
Tail {
/// Include spans matching these patterns
#[arg(long)]
include_spans: Option<String>,
/// Exclude spans matching these patterns
#[arg(long)]
exclude_spans: Option<String>,
/// Filter by attribute key=value
#[arg(long, name = "KEY=VALUE")]
r#where: Vec<String>,
/// Show traces since (e.g. 10s, 5m, 1h)
#[arg(long)]
since: Option<String>,
/// Show verbose span details
#[arg(long)]
verbose: bool,
},
}
pub async fn run(cli: Cli) -> anyhow::Result<()> {
// Initialize logging
let log_level = std::env::var("LOG_LEVEL").unwrap_or_else(|_| "info".to_string());
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(&log_level)),
)
.init();
match cli.command {
None => {
print_logo();
// Print help by re-parsing with --help
let _ = Cli::parse_from(["planoai", "--help"]);
Ok(())
}
Some(Command::Up {
file,
path,
foreground,
with_tracing,
tracing_port,
docker,
}) => up::run(file, path, foreground, with_tracing, tracing_port, docker).await,
Some(Command::Down { docker }) => down::run(docker).await,
Some(Command::Build { docker }) => build::run(docker).await,
Some(Command::Logs {
debug,
follow,
docker,
}) => logs::run(debug, follow, docker).await,
Some(Command::CliAgent {
agent_type,
file,
path,
settings,
}) => cli_agent::run(&agent_type, file, &path, &settings).await,
Some(Command::Trace { command }) => match command {
TraceCommand::Listen { host, port } => crate::trace::listen::run(&host, port).await,
TraceCommand::Down => crate::trace::down::run().await,
TraceCommand::Show { trace_id, verbose } => {
crate::trace::show::run(&trace_id, verbose).await
}
TraceCommand::Tail {
include_spans,
exclude_spans,
r#where,
since,
verbose,
} => {
crate::trace::tail::run(
include_spans.as_deref(),
exclude_spans.as_deref(),
&r#where,
since.as_deref(),
verbose,
)
.await
}
},
Some(Command::Init {
template,
clean,
output,
force,
list_templates,
}) => init::run(template, clean, output, force, list_templates).await,
}
}
fn print_logo() {
let style = console::Style::new().bold().color256(141); // closest to #969FF4
println!("{}", style.apply_to(LOGO));
println!(" The Delivery Infrastructure for Agentic Apps\n");
}

View file

@ -0,0 +1,174 @@
use std::collections::HashMap;
use std::path::Path;
use anyhow::Result;
use crate::consts::{
DEFAULT_NATIVE_OTEL_TRACING_GRPC_ENDPOINT, DEFAULT_OTEL_TRACING_GRPC_ENDPOINT,
};
use crate::utils::{
find_config_file, get_llm_provider_access_keys, is_port_in_use, load_env_file,
print_cli_header, print_missing_keys,
};
pub async fn run(
file: Option<String>,
path: String,
foreground: bool,
with_tracing: bool,
tracing_port: u16,
docker: bool,
) -> Result<()> {
let green = console::Style::new().green();
let red = console::Style::new().red();
let dim = console::Style::new().dim();
let cyan = console::Style::new().cyan();
print_cli_header();
let plano_config_file = find_config_file(&path, file.as_deref());
if !plano_config_file.exists() {
eprintln!(
"{} Config file not found: {}",
red.apply_to(""),
dim.apply_to(plano_config_file.display().to_string())
);
std::process::exit(1);
}
// Validate configuration
if !docker {
eprint!("{}", dim.apply_to("Validating configuration..."));
match crate::native::runner::validate_config(&plano_config_file) {
Ok(()) => eprintln!(" {}", green.apply_to("")),
Err(e) => {
eprintln!("\n{} Validation failed", red.apply_to(""));
eprintln!(" {}", dim.apply_to(format!("{e:#}")));
std::process::exit(1);
}
}
} else {
eprint!("{}", dim.apply_to("Validating configuration (Docker)..."));
match crate::docker::validate_config(&plano_config_file).await {
Ok(()) => eprintln!(" {}", green.apply_to("")),
Err(e) => {
eprintln!("\n{} Validation failed", red.apply_to(""));
eprintln!(" {}", dim.apply_to(format!("{e:#}")));
std::process::exit(1);
}
}
}
// Set up environment
let default_otel = if docker {
DEFAULT_OTEL_TRACING_GRPC_ENDPOINT
} else {
DEFAULT_NATIVE_OTEL_TRACING_GRPC_ENDPOINT
};
let mut env_stage: HashMap<String, String> = HashMap::new();
env_stage.insert(
"OTEL_TRACING_GRPC_ENDPOINT".to_string(),
default_otel.to_string(),
);
// Check access keys
let access_keys = get_llm_provider_access_keys(&plano_config_file)?;
let access_keys: Vec<String> = access_keys
.into_iter()
.map(|k| k.strip_prefix('$').unwrap_or(&k).to_string())
.collect();
let access_keys_set: std::collections::HashSet<_> = access_keys.into_iter().collect();
let mut missing_keys = Vec::new();
if !access_keys_set.is_empty() {
let app_env_file = if let Some(ref f) = file {
Path::new(f).parent().unwrap_or(Path::new(".")).join(".env")
} else {
Path::new(&path).join(".env")
};
if !app_env_file.exists() {
for key in &access_keys_set {
match std::env::var(key) {
Ok(val) => {
env_stage.insert(key.clone(), val);
}
Err(_) => missing_keys.push(key.clone()),
}
}
} else {
let env_dict = load_env_file(&app_env_file)?;
for key in &access_keys_set {
if let Some(val) = env_dict.get(key.as_str()) {
env_stage.insert(key.clone(), val.clone());
} else {
missing_keys.push(key.clone());
}
}
}
}
if !missing_keys.is_empty() {
print_missing_keys(&missing_keys);
std::process::exit(1);
}
env_stage.insert(
"LOG_LEVEL".to_string(),
std::env::var("LOG_LEVEL").unwrap_or_else(|_| "info".to_string()),
);
// Handle tracing
if with_tracing {
if is_port_in_use(tracing_port) {
eprintln!(
"{} Trace collector already running on port {}",
green.apply_to(""),
cyan.apply_to(tracing_port.to_string())
);
} else {
match crate::trace::listen::start_background(tracing_port).await {
Ok(()) => {
eprintln!(
"{} Trace collector listening on {}",
green.apply_to(""),
cyan.apply_to(format!("0.0.0.0:{tracing_port}"))
);
}
Err(e) => {
eprintln!(
"{} Failed to start trace collector on port {tracing_port}: {e}",
red.apply_to("")
);
std::process::exit(1);
}
}
}
let tracing_host = if docker {
"host.docker.internal"
} else {
"localhost"
};
env_stage.insert(
"OTEL_TRACING_GRPC_ENDPOINT".to_string(),
format!("http://{tracing_host}:{tracing_port}"),
);
}
// Build full env
let mut env: HashMap<String, String> = std::env::vars().collect();
env.remove("PATH");
env.extend(env_stage);
if !docker {
crate::native::runner::start_native(&plano_config_file, &env, foreground, with_tracing)
.await?;
} else {
crate::docker::start_plano(&plano_config_file, &env, foreground).await?;
}
Ok(())
}