silicon-iam-cli 1.2.1

Command-line client for Silicon IAM, built on the silicon-iam-client crate.
//! Offline command discovery and help, derived from the executable grammar.

use clap::{Arg, ArgAction, Command, CommandFactory as _, FromArgMatches as _};
use serde::Serialize;

use crate::{cli::Cli, error::Result, output::Format};

/// Use one grammar for parsing, help, error recovery and machine discovery.
pub fn command() -> Command {
    let mut command = enrich(Cli::command(), "iam");
    command = command
        .disable_help_flag(true)
        .disable_version_flag(true)
        .arg(
            Arg::new("help")
                .long("help")
                .short('h')
                .global(true)
                .action(ArgAction::HelpLong)
                .help_heading("Help and version")
                .help("Show detailed help, requirements and related documentation"),
        )
        .arg(
            Arg::new("version")
                .short('V')
                .long("version")
                .global(true)
                .action(ArgAction::Version)
                .help_heading("Help and version")
                .help("Show the installed CLI version; use `iam system version` for the backend"),
        );
    command.build();
    command
}

fn enrich(mut command: Command, path: &str) -> Command {
    command = command.mut_args(|arg| {
        let heading = if arg.is_global_set() {
            "Context and output"
        } else if arg.is_required_set() && !arg.is_positional() {
            "Required options"
        } else if matches!(
            arg.get_id().as_str(),
            "stk"
                | "app_secret"
                | "slt"
                | "refresh_token"
                | "token"
                | "subject_token"
                | "access_proof"
        ) {
            "Credentials (supply flags without a terminal)"
        } else if arg.is_positional() {
            "Arguments"
        } else {
            "Options"
        };
        arg.help_heading(heading).hide_env_values(true)
    });
    for child in command.get_subcommands_mut() {
        let child_path = format!("{path} {}", child.get_name());
        *child = enrich(child.clone(), &child_path);
    }
    let mut notes = notes(path);
    if path == "iam" {
        notes.push_str("\n\nAll commands (also available as JSON with `iam -o json commands`):\n");
        append_paths(&command, "iam", &mut notes);
    }
    command.after_help(notes.clone()).after_long_help(notes)
}

fn append_paths(command: &Command, path: &str, text: &mut String) {
    use std::fmt::Write as _;
    for child in command.get_subcommands().filter(|child| public(child)) {
        let path = format!("{path} {}", child.get_name());
        let description = child
            .get_about()
            .map(ToString::to_string)
            .unwrap_or_default();
        let _ = writeln!(
            text,
            "  {path:<38} {}",
            description.lines().next().unwrap_or("")
        );
        append_paths(child, &path, text);
    }
}

fn public(command: &Command) -> bool {
    !command.is_hide_set() && command.get_name() != "help"
}

/// Parse without reading configuration or starting an updater on help/errors.
pub fn parse() -> std::result::Result<(Cli, Vec<String>), std::process::ExitCode> {
    let mut command = command();
    let matches = match command.try_get_matches_from_mut(std::env::args_os()) {
        Ok(matches) => matches,
        Err(error) => {
            let _ = error.print();
            if matches!(
                error.kind(),
                clap::error::ErrorKind::MissingRequiredArgument
            ) {
                // Clap's usage context contains grammar, never values from argv.
                // Selecting from it avoids mistaking a flag value for a command.
                if let Some(usage) = error.get(clap::error::ContextKind::Usage) {
                    let usage = usage.to_string();
                    let mut entries = Vec::new();
                    collect_help(&mut command, &mut Vec::new(), &mut entries);
                    if let Some((_, help)) = entries.iter().rev().find(|(path, _)| {
                        let prefix = format!("Usage: iam{}", path_suffix(path));
                        usage.lines().any(|line| {
                            line.strip_prefix(&prefix)
                                .is_some_and(|rest| rest.is_empty() || rest.starts_with(' '))
                        })
                    }) {
                        eprintln!("\n{help}");
                    }
                }
            }
            return Err(std::process::ExitCode::from(
                u8::try_from(error.exit_code()).unwrap_or(2),
            ));
        }
    };
    let mut path = Vec::new();
    let mut selected = &matches;
    while let Some((name, child)) = selected.subcommand() {
        path.push(name.to_owned());
        selected = child;
    }
    let cli = Cli::from_arg_matches(&matches).map_err(|error| {
        let _ = error.print();
        std::process::ExitCode::from(2)
    })?;
    Ok((cli, path))
}

fn path_suffix(path: &[String]) -> String {
    if path.is_empty() {
        String::new()
    } else {
        format!(" {}", path.join(" "))
    }
}

fn collect_help(
    command: &mut Command,
    path: &mut Vec<String>,
    entries: &mut Vec<(Vec<String>, String)>,
) {
    entries.push((path.clone(), command.render_long_help().to_string()));
    for child in command.get_subcommands_mut().filter(|child| public(child)) {
        path.push(child.get_name().to_owned());
        collect_help(child, path, entries);
        path.pop();
    }
}

/// Show the exact leaf's help when runtime validation rejects an argument combination.
pub fn print_help(path: &[String]) {
    let mut command = command();
    let mut selected = &mut command;
    for name in path {
        let Some(child) = selected.find_subcommand_mut(name) else {
            return;
        };
        selected = child;
    }
    eprintln!("\n{}", selected.render_long_help());
}

#[derive(Serialize)]
struct Parameter {
    name: String,
    long: Option<String>,
    short: Option<char>,
    positional: bool,
    required: bool,
    global: bool,
    description: String,
    possible_values: Vec<String>,
}

#[derive(Serialize)]
struct Entry {
    command: String,
    description: String,
    usage: String,
    help: String,
    group: bool,
    arguments: Vec<Parameter>,
}

/// All public commands, directly from the same grammar used by the parser.
pub fn print_commands(format: Format) -> Result<()> {
    let mut entries = Vec::new();
    collect_entries(&mut command(), "iam", &mut entries);
    match format {
        Format::Json => crate::output::json(&entries),
        Format::Text => {
            println!("iam <command> [options]\n");
            for entry in entries {
                println!(
                    "  {:<38} {}",
                    entry.command,
                    entry.description.lines().next().unwrap_or("")
                );
            }
            println!(
                "\nRun `iam <command> --help` for exact requirements and examples.\nRun `iam docs` for offline guides, or `iam -o json commands` for machine-readable help."
            );
            Ok(())
        }
    }
}

fn collect_entries(command: &mut Command, path: &str, entries: &mut Vec<Entry>) {
    for child in command.get_subcommands_mut().filter(|child| public(child)) {
        let path = format!("{path} {}", child.get_name());
        let help = child.render_long_help().to_string();
        entries.push(Entry {
            command: path.clone(),
            description: child
                .get_long_about()
                .or_else(|| child.get_about())
                .map(ToString::to_string)
                .unwrap_or_default(),
            usage: child.render_usage().to_string(),
            help,
            group: child.get_subcommands().any(public),
            arguments: child
                .get_arguments()
                .filter(|arg| !arg.is_hide_set())
                .map(|arg| Parameter {
                    name: arg.get_id().to_string(),
                    long: arg.get_long().map(str::to_owned),
                    short: arg.get_short(),
                    positional: arg.is_positional(),
                    required: arg.is_required_set(),
                    global: arg.is_global_set(),
                    description: arg
                        .get_long_help()
                        .or_else(|| arg.get_help())
                        .map(ToString::to_string)
                        .unwrap_or_default(),
                    possible_values: arg
                        .get_possible_values()
                        .into_iter()
                        .filter(|value| !value.is_hide_set())
                        .map(|value| value.get_name().to_owned())
                        .collect(),
                })
                .collect(),
        });
        collect_entries(child, &path, entries);
    }
}

fn notes(path: &str) -> String {
    use std::fmt::Write as _;
    let specific = match path {
        "iam" => {
            "Start here:\n  iam login --email you@example.com\n  iam org list\n  iam config set org <organization-handle>\n  iam docs cli\n\nFor agents:\n  iam -o json commands                 Discover exact syntax without signing in\n  iam docs --search 'short-lived'       Search the bundled integration docs\n  iam --test <ENVIRONMENT_ID> <command> Use an isolated testing environment\n\nHelp, commands and docs are offline: no login, configuration writes or update checks.\nUse --output json for machine-readable results; next-step suggestions are text-only."
        }
        "iam docs" => {
            "Examples:\n  iam docs                             List available guides\n  iam docs cli                         Read the CLI guide\n  iam docs testing                     Read the testing workflow\n  iam docs --search 'webhook'           Find a contract or example\n  iam -o json docs authorization       Read a guide as JSON\n\nBundled documentation describes this installed version. No network or login is needed."
        }
        "iam commands" => {
            "Examples:\n  iam commands\n  iam -o json commands\n\nJSON includes full help and argument metadata for every public command.\nThe usage/help fields also describe required argument groups and conditional requirements."
        }
        "iam login" => {
            "Examples:\n  iam login --email you@example.com\n  iam --org tos login --app-id 'tos>space-station'\n\nDirect IAM Carbon login verifies a channel. With --app-id and no identity flags,\nreuse the stored session and return a single-use SLT. Give only that SLT to the\nApplication, never your IAM verification code or credential. Exchange the SLT promptly.\nA selected organization binds the Application login to that organization; --no-org\nexplicitly requests an unscoped login. Non-interactive verification needs --code."
        }
        "iam silicon-login" => {
            "Examples:\n  iam silicon-login --sid 'assistant:tos'\n  iam --org tos silicon-login --app-id 'tos>space-station'\n\nThe first command prompts for an STK on a terminal. In an agent/non-interactive\nprocess provide --stk explicitly. With only --app-id, reuse a stored Silicon\nsession and return an SLT; do not give the STK to the Application."
        }
        "iam signup" => {
            "Example:\n  iam signup --email you@example.com --phone '+14155552671' --carbon-id space-pilot\n\nIAM verifies both channels before account creation. Production signup requires\ninteractive verification. Carbon IDs use lowercase letters, digits 1-9 (not 0),\nunderscores or hyphens, and must be 3-30 characters. Signup does not select an organization."
        }
        "iam app create" => {
            "Example (replace the secret placeholder before running):\n  iam --org tos app create space-station --name 'Space Station' \\\n    --webhook-url https://spacestation.example.com/webhooks/ \\\n    --webhook-secret '<32-512-non-whitespace-ASCII-characters>' \\\n    --base-url https://spacestation.example.com\n\nRequired: APP_ID, --name, --webhook-url, --webhook-secret, --base-url.\nAPP_ID can appear before or after flags. Use a local handle with --org, or a\nquoted canonical ID such as 'tos>space-station'. Quote `>` to prevent shell redirection.\nThe Application is owned by that organization; a selected --org must match it.\n\nThe webhook secret is supplied by you. IAM returns a separate client secret\nonce: keep both in your server's secret store, never in a browser or a report.\nThe base URL is an origin: no path or trailing slash (the scheme's // is required).\nA webhook URL may have a path and trailing slash; IAM uses the supplied endpoint.\n\nNew applications are verified; production webhook destinations need approval.\nTesting endpoints activate immediately in the selected environment. Successful output includes relevant next commands."
        }
        "iam app approve-webhook" => {
            "Requirements:\n  Direct Carbon session; current owning-org owner/admin or IAM applications.review.\n  The application must be verified and have a pending webhook destination.\n  A fresh --step-up assertion is required.\n\nWorkflow:\n  iam app webhook 'org>app'\n  iam step-up application.webhook.approve <APPLICATION_UUID>\n  iam --step-up '<ASSERTION>' app approve-webhook 'org>app'\n\nUse the internal Application UUID shown by `iam app webhook`, not org>app,\nfor step-up. IAM reviewers can inspect that endpoint without org membership.\nCheck the pending URL before approving. Approval activates only that endpoint;\nit does not approve scopes or change application status. Testing environments\nalready activate endpoints automatically, so normally have nothing to approve."
        }
        "iam app set-webhook" => {
            "Production keeps the current active endpoint while the replacement awaits\napproval. Inspect it with `iam app webhook`, then use `iam app approve-webhook`\nwith a fresh application.webhook.approve step-up. The owning organization's\ncurrent owner/admins and IAM reviewers can approve. Testing environments\nactivate replacements immediately. See `iam app approve-webhook --help`."
        }
        "iam app import" => {
            "Example:\n  iam --test <ENVIRONMENT_ID> app import 'tos>space-station'\n\nFirst obtain the environment key with `iam env key <ENVIRONMENT_ID>` in the\nproduction control plane, then sign in or sign up inside that test environment.\nThe source ID must be canonical. The test Carbon must administer any existing\ntarget organization. A fresh import does not change production or other environments.\nKeep the returned test client secret separate from the production credential."
        }
        "iam app token exchange" => {
            "Workflow:\n  iam --org tos login --app-id 'tos>space-station'\n  iam --org tos app token exchange 'tos>space-station'\n\nSupply the SLT and Application secret at the protected terminal prompts, or use\n--slt and --app-secret for a non-interactive process. SLTs expire quickly and\nare single-use: do not retry a consumed SLT as a new login. Preserve the returned\nrefresh token securely. Read `iam docs authorization` for initial access synchronization."
        }
        "iam app token authorization" | "iam app token introspect" => {
            "Use the same Application ID/secret, organization and testing environment that\nissued the access token. Immediately fetch authorization after login, including\non an empty application cache; no directory edit or webhook round-trip is needed.\nMissing/undisclosed role or tag fields do not grant authority. Re-check current\nauthority after epoch changes. See `iam docs authorization` for the exact contract."
        }
        "iam app obo exchange" | "iam app obo verify" => {
            "OBO proofs bind the subject, audience, registered endpoint, HTTP method and exact\nbody bytes. Keep those inputs identical; verification consumes a proof once.\nA valid actor alone is not an admin/tag grant: use the verified authorization\nbinding. Role/tag changes can invalidate a previously issued proof.\nSee `iam docs obo` before implementing a downstream authorization decision."
        }
        "iam app verify-webhook" => {
            "Verify the exact raw body bytes and all signed headers before parsing or acting.\nUse --body-file where offered on OBO commands; this command takes BODY_FILE as\na positional argument. `-` reads stdin. Do not reserialize JSON before verification.\nUse the configured signing secret for the delivered key version, not the client secret."
        }
        "iam step-up" => {
            "Workflow:\n  1. Read the target with its show command to obtain its internal UUID.\n  2. Run `iam step-up <ACTION> <RESOURCE_ID>` in the same organization/environment.\n  3. Pass the returned assertion using --step-up on the intended mutation.\n\nUse the exact action and resource type printed in that mutation's help.\nA handle is not a resource UUID. Assertions are short-lived and cannot authorize\na different action or resource. A direct Carbon session is required."
        }
        "iam env" | "iam env create" | "iam env key" => {
            "Testing workflow:\n  iam --org tos env create 'integration-check'\n  iam env key <ENVIRONMENT_ID>\n  iam --test <ENVIRONMENT_ID> signup --help\n  iam --test <ENVIRONMENT_ID> app import 'tos>space-station'\n\nLifecycle commands use the production control plane: omit --test and unset\nSILICON_IAM_TEST if set. A production organization/member is not automatically\ncopied into the test plane; sign up or log in there separately. Pass the public\nenvironment UUID to --test, never its root key. Keys are stored privately per profile."
        }
        "iam env clean" => {
            "WARNING: deletes data inside the targeted testing environment. There is no undo.\nWith an explicit ID, use the production control plane (no --test). Without an ID,\n--test selects the environment and uses this profile's stored key.\nRead `iam env show <ENVIRONMENT_ID>` or `iam --test <ENVIRONMENT_ID> env current`\nfirst to confirm the target. After cleaning, sign up and import again; old tokens\nand memberships no longer provide access. Other environments remain separate."
        }
        "iam config" | "iam config set" | "iam config use" => {
            "Examples:\n  iam config show\n  iam config set org tos\n  iam --profile local config set url http://127.0.0.1:58080\n  iam --test <ENVIRONMENT_ID> config set org tos\n  iam config set auto-update off\n\nCommand flags override environment variables, which override profile defaults.\nTest organizations/sessions are stored separately; setting a test org does not\nchange the production default. SILICON_IAM_HOME chooses a private credential\ndirectory. Help and docs remain usable even when that directory is unavailable."
        }
        _ => "",
    };
    let mut text = specific.to_owned();
    if path == "iam" || matches!(path, "iam docs" | "iam commands") {
        return text;
    }
    let family = path.split_whitespace().nth(1).unwrap_or("cli");
    let topic = match family {
        "app" => "applications",
        "env" => "testing",
        "config" => "storage",
        _ => "cli",
    };
    if !text.is_empty() {
        text.push_str("\n\n");
    }
    let _ = write!(
        text,
        "Reference:\n  iam docs {topic}\n  iam docs --search '{family}'\n  iam commands"
    );
    if !matches!(family, "config" | "system") {
        text.push_str("\n\nContext:\n  Keep --profile, --url and --test consistent across a workflow. Use --org for\n  organization-scoped actions. `iam config show` reveals resolved settings.\n  --output json returns structured data without next-step suggestions.\n  In non-interactive use, supply credential/code flags explicitly; no hidden prompts.");
    }
    text
}