sylphx-cli 0.1.0

Sylphx Platform CLI — dogfoods the Rust Management SDK
//! Sylphx Platform CLI — thin façade over `sylphx-sdk-management`.
//!
//! Sole Rust operator surface for the Management plane (ADR-3820). No TypeScript
//! fallback. No stub command modules.
//!
//! ## Shipped product commands
//!
//! Typed: `health`, `whoami`, `orgs list`, `projects list|get`, `version-info`.
//! Escape hatch: `api <METHOD> <path>` — full Management REST drop-in through
//! the same authenticated transport as the typed SDK (parity with retired TS
//! `sylphx api`).

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::ManagementClient;

#[derive(Parser, Debug)]
#[command(
    name = "sylphx",
    about = "Sylphx Platform CLI (Rust) — dogfoods sylphx-sdk-management",
    version
)]
struct Cli {
    /// Management API base URL (include /v1).
    #[arg(long, env = "SYLPHX_API_URL", global = true)]
    api_url: Option<String>,

    /// Management token (OAuth JWT or svc_*).
    #[arg(long, env = "SYLPHX_TOKEN", global = true)]
    token: Option<String>,

    /// Emit machine-readable JSON on stdout.
    #[arg(long, global = true, default_value_t = false)]
    json: bool,

    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand, Debug)]
enum Commands {
    /// Process health (Management plane).
    Health,
    /// Authenticated principal.
    Whoami,
    /// Organization operations (from WhoAmI).
    Orgs {
        #[command(subcommand)]
        command: OrgCommands,
    },
    /// Project operations.
    Projects {
        #[command(subcommand)]
        command: ProjectCommands,
    },
    /// Authenticated raw Management REST (escape hatch; full REST drop-in).
    Api {
        /// HTTP method.
        method: ApiMethod,
        /// Path relative to Management base (e.g. /projects).
        path: String,
        /// Optional JSON body (inline string). Mutually exclusive with --input.
        #[arg(long)]
        body: Option<String>,
        /// Read JSON body from file (`-` = stdin).
        #[arg(long)]
        input: Option<PathBuf>,
        /// Optional org scope header.
        #[arg(long)]
        org_id: Option<String>,
    },
    /// Print contract / SDK identity for audit.
    VersionInfo,
}

#[derive(Subcommand, Debug)]
enum OrgCommands {
    /// List organizations for the authenticated principal.
    List,
}

#[derive(Subcommand, Debug)]
enum ProjectCommands {
    /// List projects.
    List {
        /// Optional org scope header.
        #[arg(long)]
        org_id: Option<String>,
    },
    /// Get a project by id.
    Get {
        /// Project id.
        id: String,
        #[arg(long)]
        org_id: Option<String>,
    },
}

#[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,
        }
    }
}

#[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",
                "shippedCommands": [
                    "health",
                    "whoami",
                    "orgs list",
                    "projects list",
                    "projects get",
                    "api",
                    "version-info"
                ],
            });
            print_out(&cli, &body, || {
                println!(
                    "sylphx {}  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::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() {
                // empty body
            } 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();
    }
}