//! PiCloud command-line client. //! //! Thin client over the existing admin + execute HTTP surface — the //! server gains nothing for the CLI; the CLI is just a developer //! ergonomics layer over endpoints the dashboard already uses. use std::path::PathBuf; use std::process::ExitCode; use clap::{Args, Parser, Subcommand}; mod client; mod cmds; mod config; mod output; use crate::output::OutputMode; #[derive(Parser)] #[command(name = "pic", version, about = "PiCloud command-line client")] struct Cli { /// Output format for `ls` / `show` / `whoami` / `logs` commands. /// TSV stays pipe-friendly; JSON is `jq`-ready. #[arg(long, value_enum, global = true, default_value_t = OutputMode::Tsv)] output: OutputMode, #[command(subcommand)] cmd: Cmd, } #[derive(Subcommand)] enum Cmd { /// Authenticate with the server. Default flow prompts for username /// + password and saves the returned session token; `--token` skips /// the password exchange and persists a bearer string directly (use /// this for long-lived API keys minted via `pic api-keys mint`). Login(LoginArgs), /// Revoke the saved session server-side and delete the local /// credentials file. Idempotent. Logout, /// Print the principal the saved token resolves to. Whoami, /// App management. Apps { #[command(subcommand)] cmd: AppsCmd, }, /// Script management. Scripts { #[command(subcommand)] cmd: ScriptsCmd, }, /// Long-lived bearer API key management. #[command(name = "api-keys")] ApiKeys { #[command(subcommand)] cmd: ApiKeysCmd, }, /// Tail recent execution logs for a script. Logs(LogsArgs), /// Top-level alias for `pic scripts invoke `. Invoke(InvokeArgs), /// Top-level alias for `pic scripts deploy --app `. Deploy(DeployArgs), } #[derive(Args)] struct LoginArgs { /// Override the URL prompt non-interactively. Also reads /// `PICLOUD_URL`. #[arg(long)] url: Option, /// Skip the username + password exchange and persist this bearer /// directly (validated against `/auth/me` first). Also reads /// `PICLOUD_TOKEN`. #[arg(long)] token: Option, } #[derive(Subcommand)] enum AppsCmd { /// List apps the caller can see. Ls, /// Create a new app. Create { slug: String, #[arg(long)] name: Option, #[arg(long)] description: Option, }, /// Show a single app, including the caller's role in it. Show { ident: String }, /// Delete an app. Without `--force`, the server rejects if the app /// still owns scripts. Delete { ident: String, #[arg(long)] force: bool, }, } #[derive(Subcommand)] enum ScriptsCmd { /// List scripts. With `--app`, scoped to one app; without, one /// `GET /admin/scripts` for everything the caller can see. Ls { #[arg(long)] app: Option, }, /// Upload a `.rhai` file. Patches the existing script with the /// matching name in `--app` if one exists, otherwise creates it. Deploy(DeployArgs), /// POST to `/api/v1/execute/{id}`. Body via `--body @path`, /// `--body @-` for stdin, or inline JSON. Invoke(InvokeArgs), /// Delete a script. Requires AppAdmin on the owning app. Delete { id: String }, } #[derive(Args)] struct DeployArgs { file: PathBuf, #[arg(long)] app: String, #[arg(long)] name: Option, #[arg(long)] description: Option, } #[derive(Args)] struct InvokeArgs { id: String, #[arg(long)] body: Option, #[arg(short = 'H', long = "header", value_parser = client::parse_kv_header)] headers: Vec<(String, String)>, } #[derive(Subcommand)] enum ApiKeysCmd { /// Mint a new long-lived bearer key. Token printed exactly once. Mint { name: String, /// Repeat for multiple scopes: `--scope script:read --scope log:read`. #[arg(long = "scope", required = true)] scopes: Vec, /// Bind the key to a single app (slug or id). Rejects /// `instance:*` scopes when set. #[arg(long)] app: Option, /// Absolute RFC 3339 (`2026-12-31T23:59:59Z`) or shorthand /// `d`/`h`/`m`. #[arg(long)] expires: Option, }, /// List the caller's keys (no `raw_token` after mint). Ls, /// Revoke a key by id. Rm { id: String }, } #[derive(Args)] struct LogsArgs { script_id: String, #[arg(long, default_value_t = 50)] limit: u32, } #[tokio::main(flavor = "current_thread")] async fn main() -> ExitCode { let cli = Cli::parse(); let mode = cli.output; let result = match cli.cmd { Cmd::Login(args) => cmds::login::run(args.url.as_deref(), args.token.as_deref()).await, Cmd::Logout => cmds::logout::run().await, Cmd::Whoami => cmds::whoami::run(mode).await, Cmd::Apps { cmd: AppsCmd::Ls } => cmds::apps::ls(mode).await, Cmd::Apps { cmd: AppsCmd::Create { slug, name, description, }, } => cmds::apps::create(&slug, name.as_deref(), description.as_deref()).await, Cmd::Apps { cmd: AppsCmd::Show { ident }, } => cmds::apps::show(&ident, mode).await, Cmd::Apps { cmd: AppsCmd::Delete { ident, force }, } => cmds::apps::delete(&ident, force).await, Cmd::Scripts { cmd: ScriptsCmd::Ls { app }, } => cmds::scripts::ls(app.as_deref(), mode).await, Cmd::Scripts { cmd: ScriptsCmd::Deploy(args), } => { cmds::scripts::deploy( &args.file, &args.app, args.name.as_deref(), args.description.as_deref(), ) .await } Cmd::Scripts { cmd: ScriptsCmd::Invoke(args), } => cmds::scripts::invoke(&args.id, args.body.as_deref(), &args.headers).await, Cmd::Scripts { cmd: ScriptsCmd::Delete { id }, } => cmds::scripts::delete(&id).await, Cmd::ApiKeys { cmd: ApiKeysCmd::Mint { name, scopes, app, expires, }, } => cmds::api_keys::mint(&name, &scopes, app.as_deref(), expires.as_deref(), mode).await, Cmd::ApiKeys { cmd: ApiKeysCmd::Ls, } => cmds::api_keys::ls(mode).await, Cmd::ApiKeys { cmd: ApiKeysCmd::Rm { id }, } => cmds::api_keys::rm(&id).await, Cmd::Logs(LogsArgs { script_id, limit }) => cmds::logs::run(&script_id, limit, mode).await, Cmd::Invoke(args) => { cmds::scripts::invoke(&args.id, args.body.as_deref(), &args.headers).await } Cmd::Deploy(args) => { cmds::scripts::deploy( &args.file, &args.app, args.name.as_deref(), args.description.as_deref(), ) .await } }; match result { Ok(()) => ExitCode::SUCCESS, Err(err) => { output::print_error(&err); ExitCode::FAILURE } } }