use std::path::PathBuf;
use std::process::ExitCode;
use std::time::Duration;
use clap::{Parser, Subcommand, ValueEnum};
use reqwest::Method;
use serde_json::{json, Value};
use sylphx_sdk_core::{RetryConfig, DEFAULT_MANAGEMENT_BASE_URL};
use sylphx_sdk_management::{ListOptions, ManagementClient};
#[derive(Parser, Debug)]
#[command(
name = "sylphx",
about = "Sylphx Platform CLI (Rust) — dogfoods sylphx-sdk-management",
version
)]
struct Cli {
#[arg(long, env = "SYLPHX_API_URL", global = true)]
api_url: Option<String>,
#[arg(long, env = "SYLPHX_TOKEN", global = true)]
token: Option<String>,
#[arg(long, global = true, default_value_t = false)]
json: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
Health,
Whoami,
Orgs {
#[command(subcommand)]
command: OrgCommands,
},
Projects {
#[command(subcommand)]
command: ProjectCommands,
},
Environments {
#[command(subcommand)]
command: EnvironmentCommands,
},
Deployments {
#[command(subcommand)]
command: DeploymentCommands,
},
Api {
method: ApiMethod,
path: String,
#[arg(long)]
body: Option<String>,
#[arg(long)]
input: Option<PathBuf>,
#[arg(long)]
org_id: Option<String>,
},
VersionInfo,
}
#[derive(Subcommand, Debug)]
enum OrgCommands {
List,
}
#[derive(Subcommand, Debug)]
enum ProjectCommands {
List {
#[arg(long)]
org_id: Option<String>,
},
Get {
id: String,
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand, Debug)]
enum EnvironmentCommands {
List {
project_id: String,
#[arg(long)]
org_id: Option<String>,
#[arg(long)]
page_token: Option<String>,
#[arg(long)]
page_size: Option<u32>,
},
Get {
project_id: String,
environment_id: String,
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand, Debug)]
enum DeploymentCommands {
List {
project_id: String,
#[arg(long)]
org_id: Option<String>,
#[arg(long)]
page_token: Option<String>,
#[arg(long)]
page_size: Option<u32>,
},
}
#[derive(Clone, Debug, ValueEnum)]
enum ApiMethod {
Get,
Head,
Post,
Put,
Patch,
Delete,
}
impl ApiMethod {
fn to_reqwest(self) -> Method {
match self {
Self::Get => Method::GET,
Self::Head => Method::HEAD,
Self::Post => Method::POST,
Self::Put => Method::PUT,
Self::Patch => Method::PATCH,
Self::Delete => Method::DELETE,
}
}
}
fn page_opts(page_token: Option<String>, page_size: Option<u32>) -> Option<ListOptions> {
if page_token.is_none() && page_size.is_none() {
return None;
}
Some(ListOptions {
page_token,
page_size,
})
}
#[tokio::main]
async fn main() -> ExitCode {
match run().await {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("error: {err}");
ExitCode::from(1)
}
}
}
async fn run() -> anyhow::Result<()> {
let cli = Cli::parse();
match &cli.command {
Commands::VersionInfo => {
let body = json!({
"cli": env!("CARGO_PKG_VERSION"),
"contractPackage": sylphx_wire::CONTRACT_PACKAGE,
"contractRevision": sylphx_wire::CONTRACT_REVISION,
"client": "sylphx-sdk-management",
"product": "rust",
"shippedCommands": [
"health",
"whoami",
"orgs list",
"projects list",
"projects get",
"environments list",
"environments get",
"deployments list",
"api",
"version-info"
],
});
print_out(&cli, &body, || {
println!(
"sylphx {} (Rust) contract {}@{} via sylphx-sdk-management",
env!("CARGO_PKG_VERSION"),
sylphx_wire::CONTRACT_PACKAGE,
sylphx_wire::CONTRACT_REVISION
);
});
return Ok(());
}
_ => {}
}
let token = cli
.token
.clone()
.filter(|t| !t.trim().is_empty())
.ok_or_else(|| anyhow::anyhow!("SYLPHX_TOKEN or --token is required"))?;
let base = cli
.api_url
.clone()
.unwrap_or_else(|| DEFAULT_MANAGEMENT_BASE_URL.to_string());
let client = ManagementClient::builder(token)
.base_url(base)
.retry(RetryConfig {
max_attempts: 3,
base_delay: Duration::from_millis(100),
})
.build()?;
match cli.command {
Commands::Health => {
let h = client.health().await?;
print_out(&cli, &h, || {
println!(
"status={} version={} git_sha={}",
h.status,
h.version.as_deref().unwrap_or("-"),
h.git_sha.as_deref().unwrap_or("-")
);
});
}
Commands::Whoami => {
let w = client.whoami().await?;
print_out(&cli, &w, || {
let user = w.user.as_ref();
println!(
"user_id={} email={} name={} orgs={}",
user.map(|u| u.id.as_str()).unwrap_or("-"),
user.map(|u| u.email.as_str()).unwrap_or("-"),
user.and_then(|u| u.name.as_deref()).unwrap_or("-"),
w.orgs.len()
);
});
}
Commands::Orgs {
command: OrgCommands::List,
} => {
let orgs = client.list_orgs().await?;
if cli.json {
println!("{}", serde_json::to_string_pretty(&orgs)?);
} else {
for o in &orgs {
println!("{}\t{}\t{}", o.id, o.slug, o.name);
}
if orgs.is_empty() {
println!("(no orgs)");
}
}
}
Commands::Projects {
command: ProjectCommands::List { org_id },
} => {
let list = client.list_projects(org_id.as_deref(), None).await?;
if cli.json {
println!("{}", serde_json::to_string_pretty(&list)?);
} else {
for p in &list.data {
println!("{}\t{}\t{}", p.id, p.slug, p.name);
}
if list.data.is_empty() {
println!("(no projects)");
}
}
}
Commands::Projects {
command: ProjectCommands::Get { id, org_id },
} => {
let p = client.get_project(&id, org_id.as_deref()).await?;
if cli.json {
println!("{}", serde_json::to_string_pretty(&p)?);
} else {
println!(
"id={} slug={} name={} org_id={} active={}",
p.id, p.slug, p.name, p.org_id, p.is_active
);
}
}
Commands::Environments {
command:
EnvironmentCommands::List {
project_id,
org_id,
page_token,
page_size,
},
} => {
let page = page_opts(page_token, page_size);
let list = client
.list_environments(&project_id, page.as_ref(), org_id.as_deref())
.await?;
if cli.json {
println!("{}", serde_json::to_string_pretty(&list)?);
} else {
for e in &list.data {
println!(
"{}\t{}\t{}\t{}",
e.id,
e.slug,
e.name,
e.status.as_deref().unwrap_or("-")
);
}
if list.data.is_empty() {
println!("(no environments)");
}
}
}
Commands::Environments {
command:
EnvironmentCommands::Get {
project_id,
environment_id,
org_id,
},
} => {
let e = client
.get_environment(&project_id, &environment_id, org_id.as_deref())
.await?;
if cli.json {
println!("{}", serde_json::to_string_pretty(&e)?);
} else {
println!(
"id={} slug={} name={} project_id={} status={}",
e.id,
e.slug,
e.name,
e.project_id,
e.status.as_deref().unwrap_or("-")
);
}
}
Commands::Deployments {
command:
DeploymentCommands::List {
project_id,
org_id,
page_token,
page_size,
},
} => {
let page = page_opts(page_token, page_size);
let list = client
.list_deployments(&project_id, page.as_ref(), org_id.as_deref())
.await?;
if cli.json {
println!("{}", serde_json::to_string_pretty(&list)?);
} else {
for d in &list.data {
println!(
"{}\t{}\t{}\t{}",
d.id,
d.status,
d.environment_id.as_deref().unwrap_or("-"),
d.git_sha.as_deref().unwrap_or("-")
);
}
if list.data.is_empty() {
println!("(no deployments)");
}
}
}
Commands::Api {
method,
path,
body,
input,
org_id,
} => {
let payload = load_body(body.as_deref(), input.as_ref())?;
let (status, value) = client
.api(
method.to_reqwest(),
&path,
payload.as_ref(),
org_id.as_deref(),
)
.await?;
if cli.json {
let envelope = json!({"status": status, "body": value});
println!("{}", serde_json::to_string_pretty(&envelope)?);
} else if value.is_null() {
} else {
println!("{}", serde_json::to_string_pretty(&value)?);
}
}
Commands::VersionInfo => unreachable!(),
}
Ok(())
}
fn load_body(
inline: Option<&str>,
input: Option<&PathBuf>,
) -> anyhow::Result<Option<Value>> {
match (inline, input) {
(Some(_), Some(_)) => Err(anyhow::anyhow!(
"use only one of --body or --input for request payload"
)),
(Some(s), None) => Ok(Some(serde_json::from_str(s)?)),
(None, Some(path)) => {
let text = if path.as_os_str() == "-" {
use std::io::Read;
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
buf
} else {
std::fs::read_to_string(path)?
};
if text.trim().is_empty() {
Ok(None)
} else {
Ok(Some(serde_json::from_str(&text)?))
}
}
(None, None) => Ok(None),
}
}
fn print_out<T: serde::Serialize>(cli: &Cli, value: &T, human: impl FnOnce()) {
if cli.json {
println!(
"{}",
serde_json::to_string_pretty(value).expect("serialize json")
);
} else {
human();
}
}