use clap::{Args, Parser, Subcommand};
use uuid::Uuid;
#[derive(Debug, Parser)]
#[command(
name = "hook",
version,
about = "Manage signed provider webhooks and their delivery status.",
long_about = "Manage Silicon Hook through its official Rust client. Sign in with an IAM short-lived token. Applications handle receiving internally; this CLI does not run a delivery daemon. Use the same commands in a sandbox with --test <environment-id>.",
after_help = "Start: hook iam --json; hook login <slt> --org tos\nThen: hook --silicon si:cos create GitHub\nInspect: hook events; hook publication <event-id>\nExplore: hook commands; hook <command> --help\nDocs: https://docs.hook.teamofsilicons.com · Source: https://github.com/teamofsilicons/silicon-hook\nRust: https://crates.io/crates/silicon-hook-client · Bugs: hook report --help"
)]
pub struct Cli {
#[arg(
long,
global = true,
default_value = "default",
help = "Independent local identity/profile"
)]
pub profile: String,
#[arg(
long,
global = true,
env = "SILICON_HOOK_URL",
help = "Backend origin; defaults to backend.hook.teamofsilicons.com"
)]
pub url: Option<String>,
#[arg(
long,
global = true,
env = "SILICON_HOOK_ORG",
help = "Organization handle in the selected production/test plane"
)]
pub org: Option<String>,
#[arg(
long,
global = true,
help = "Target Silicon, e.g. si:cos; defaults to the signed-in Silicon"
)]
pub silicon: Option<String>,
#[arg(
long,
global = true,
help = "Test environment UUID; its root key is stored separately"
)]
pub test: Option<Uuid>,
#[arg(
long,
global = true,
conflicts_with = "test",
help = "Use the separate production session for this command"
)]
pub production: bool,
#[arg(
long,
global = true,
help = "Structured JSON output without next-step prose"
)]
pub json: bool,
#[arg(
long,
global = true,
help = "Reuse this key when retrying the same mutation"
)]
pub idempotency_key: Option<String>,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
Report {
message: String,
#[arg(
long,
help = "Optional silicon-hook GitHub pull request URL containing a fix"
)]
pr: Option<String>,
},
About,
Login(Login),
Iam,
Logout,
Whoami,
Create {
name: String,
#[arg(long)]
description: Option<String>,
#[arg(long)]
time_zone: Option<String>,
#[arg(
long,
help = "Signature policy JSON or @file; see hook docs signatures"
)]
signature: Option<String>,
#[arg(
long,
help = "BYOS secret file; '-' reads stdin. Preserves spaces; removes one trailing line ending"
)]
secret_file: Option<String>,
#[arg(long, help = "Accept unsigned requests for this hook")]
unsigned: bool,
},
SetSecret {
id: Uuid,
#[arg(
long,
help = "Secret file; '-' reads stdin. Preserves spaces; removes one trailing line ending"
)]
secret_file: String,
#[arg(long, value_parser = ["utf8", "ascii", "hex", "base64", "base64url", "raw"])]
secret_encoding: Option<String>,
},
List {
#[arg(long)]
include_deleted: bool,
},
Show { id: Uuid },
Update {
id: Uuid,
#[arg(
long,
help = "JSON or @file: name, description, time_zone, enabled, signature"
)]
patch: String,
},
Delete { id: Uuid },
Restore { id: Uuid },
Enable {
#[arg(required = true)]
ids: Vec<Uuid>,
},
Disable {
#[arg(required = true)]
ids: Vec<Uuid>,
},
Rotate {
#[command(subcommand)]
kind: Rotate,
},
Events(History),
Blocked(History),
Event { id: Uuid },
Publication { event_id: Uuid },
Publisher {
#[command(subcommand)]
action: Publisher,
},
Receiving {
#[command(subcommand)]
action: Receiving,
},
ConnectIam,
Env {
#[command(subcommand)]
action: Environment,
},
Config {
#[command(subcommand)]
action: Config,
},
System {
#[command(subcommand)]
action: System,
},
Commands,
Docs {
#[arg(default_value = "overview")]
topic: String,
},
}
#[derive(Debug, Args)]
#[command(
args_conflicts_with_subcommands = true,
subcommand_negates_reqs = true,
after_help = "Next: hook login status --json to verify your identity; hook list to inspect provider webhooks. Applications handle receiving internally."
)]
pub struct Login {
#[command(subcommand)]
pub action: Option<LoginAction>,
#[arg(
value_name = "SLT",
conflicts_with_all = ["slt", "slt_file"],
help = "IAM short-lived token (use --slt-file to avoid shell history)"
)]
pub token: Option<String>,
#[arg(
long,
conflicts_with_all = ["slt_file", "token"],
help = "Short-lived IAM token; --slt-file avoids shell history"
)]
pub slt: Option<String>,
#[arg(
long,
required_unless_present_any = ["slt", "token"],
help = "File containing only the short-lived token; '-' reads stdin"
)]
pub slt_file: Option<String>,
}
#[derive(Debug, Subcommand)]
pub enum LoginAction {
Status,
}
#[derive(Debug, Subcommand)]
pub enum Rotate {
Endpoint { id: Uuid },
Secret { id: Uuid },
}
#[derive(Debug, Args)]
pub struct History {
#[arg(long)]
pub hook: Option<Uuid>,
#[arg(long, default_value_t = 100, help = "Number of requests, 1–10000")]
pub limit: u32,
#[arg(long, help = "Opaque next_cursor returned by a prior page")]
pub cursor: Option<String>,
}
#[derive(Debug, Subcommand)]
pub enum Publisher {
#[command(
after_help = "Internal application setup. Requires --idempotency-key; reuse the same key and SLT file after an interrupted request. No --silicon selection is needed."
)]
Provision {
#[arg(long, help = "Dedicated publisher's Hook SLT file; '-' reads stdin")]
slt_file: String,
#[arg(
long,
help = "Explicitly recover an existing rejected publisher family"
)]
replace_rejected: bool,
},
}
#[derive(Debug, Subcommand)]
pub enum Receiving {
Authorize,
Complete {
authorization_id: Uuid,
#[arg(long)]
code_file: String,
},
AuthorizationStatus,
DisconnectAuthorization,
Scope,
#[command(
after_help = "Requires a selected test environment and --idempotency-key. Save `hook receiving scope` to a file first. Retry uncertainty with the same scope file and key. Renew explicitly with --receiver-id and a new key. The output file must not already exist; no token is printed and no daemon is started. Windows uses a verified owner-only ACL and may retain an empty reservation after failure; retry with a new output path and the original scope and key."
)]
Bootstrap {
#[arg(long, help = "Pinned JSON scope file, retained unchanged for retries")]
scope_file: String,
#[arg(long, help = "New private 0600 capability file; never overwritten")]
output: String,
#[arg(
long,
help = "Existing receiver ID to renew explicitly with a new operation key"
)]
receiver_id: Option<String>,
},
Register,
Status,
Subscribe,
Unsubscribe,
}
#[derive(Debug, Subcommand)]
pub enum Environment {
Use {
#[arg(long, help = "File containing the IAM app_secret; '-' reads stdin")]
app_secret_file: String,
},
Exit,
Create {
name: String,
#[arg(long)]
description: Option<String>,
#[arg(long)]
iam_key_file: String,
#[arg(
long,
help = "Optional JSON file with test app_id, app_secret, webhook_secret, webhook_secret_version"
)]
iam_config: Option<String>,
},
Attach {
id: Uuid,
#[arg(long)]
key_file: String,
},
List {
#[arg(long,default_value="active",value_parser=["active","deleted","all"])]
status: String,
#[arg(long, default_value_t = 100, help = "Page size, 1–1000")]
limit: u32,
#[arg(long, help = "Last environment UUID from the previous descending page")]
after: Option<Uuid>,
},
Show {
id: Uuid,
},
Key {
id: Uuid,
},
RotateKey {
id: Uuid,
},
Delete {
id: Uuid,
},
Restore {
id: Uuid,
},
Current,
Clean,
ConfigureIam {
#[arg(long)]
file: String,
},
}
#[derive(Debug, Subcommand)]
pub enum Config {
Show,
Profiles,
Home { location: String },
Set {
#[arg(value_parser=["url","org","silicon","telemetry"])]
key: String,
value: String,
},
}
#[derive(Debug, Subcommand)]
pub enum System {
Version,
Health,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn login_supports_token_only_and_status_without_tokens() {
let cli = Cli::try_parse_from(["hook", "login", "opaque-slt"]).unwrap();
assert!(matches!(
cli.command,
Command::Login(Login { token: Some(_), .. })
));
let cli = Cli::try_parse_from(["hook", "login", "status", "--json"]).unwrap();
assert!(cli.json);
assert!(matches!(
cli.command,
Command::Login(Login {
action: Some(LoginAction::Status),
..
})
));
assert!(Cli::try_parse_from(["hook", "login"]).is_err());
assert!(Cli::try_parse_from(["hook", "login", "status", "--slt", "secret"]).is_err());
}
#[test]
fn receiving_and_discovery_support_test_context() {
for command in [
vec!["iam", "--json"],
vec!["receiving", "register"],
vec!["receiving", "status"],
vec!["receiving", "subscribe"],
vec!["receiving", "unsubscribe"],
vec!["event", "00000000-0000-4000-8000-000000000001"],
vec!["publication", "00000000-0000-4000-8000-000000000001"],
] {
let mut args = vec![
"hook",
"--profile",
"reviewer",
"--test",
"00000000-0000-4000-8000-000000000001",
];
args.extend(command);
let cli = Cli::try_parse_from(args).unwrap();
assert!(cli.test.is_some());
assert_eq!(cli.profile, "reviewer");
}
}
#[test]
fn retired_transports_and_login_destination_flags_are_not_accepted() {
for command in [
vec!["webhook", "http://127.0.0.1/events"],
vec!["unhook"],
vec!["daemon", "start"],
vec!["listen"],
vec!["deliveries", "cursor"],
vec!["deliveries", "ack", "1"],
vec!["login", "slt", "--webhook-url", "http://127.0.0.1/events"],
vec!["--isi", "internal-id", "iam"],
] {
let mut args = vec!["hook"];
args.extend(command);
assert!(Cli::try_parse_from(args).is_err());
}
}
#[test]
fn byos_commands_support_files_stdin_and_test_context() {
let id = "00000000-0000-4000-8000-000000000001";
for command in [
vec!["create", "Stripe", "--secret-file", "-"],
vec![
"set-secret",
id,
"--secret-file",
"provider.txt",
"--secret-encoding",
"hex",
],
] {
let mut args = vec!["hook", "--test", id];
args.extend(command);
assert!(Cli::try_parse_from(args).is_ok());
}
assert!(Cli::try_parse_from(["hook", "set-secret", id]).is_err());
assert!(
Cli::try_parse_from([
"hook",
"set-secret",
id,
"--secret-file",
"-",
"--secret-encoding",
"invalid"
])
.is_err()
);
}
}