mod commands;
mod daemon;
#[cfg(feature = "tui")]
mod navigator;
mod output;
mod runtime;
mod secret_input;
mod vault_cmd;
use std::path::PathBuf;
use chrono::Duration;
use clap::{Parser, Subcommand, ValueEnum};
use crate::{
config::VaultMode, error::Result, types::Owner,
DEFAULT_SECRET_TTL_DAYS as DEFAULT_CONFIG_SECRET_TTL_DAYS,
};
const DEFAULT_AGENT_ID: &str = "default-agent";
const DEFAULT_ROOT_DIR: &str = ".openclaw/secrets";
const DEFAULT_TTL_DAYS: i64 = DEFAULT_CONFIG_SECRET_TTL_DAYS;
const DEFAULT_TTL_SECONDS: i64 = 86_400;
const DEFAULT_DAEMON_REQUEST_LIMIT_BYTES: usize = 16 * 1024;
const DEFAULT_DAEMON_BIND: &str = "127.0.0.1:7788";
const DEFAULT_DAEMON_IO_TIMEOUT_SECONDS: u64 = 5;
const DEFAULT_VAULT_MOUNT_TTL: &str = "1h";
const DEFAULT_VAULT_SECRET_TTL_DAYS: i64 = 365;
const DEFAULT_VAULT_SECRET_LENGTH_BYTES: usize = 64;
const SECRET_NAME_ARG_HELP: &str =
"Secret id (example: `service/token`). Allowed characters: letters, digits, `.`, `_`, `-`, and `/`.";
const REQUEST_ID_ARG_HELP: &str =
"Request UUID from `gloves requests list` (example: `123e4567-e89b-12d3-a456-426614174000`).";
const ERROR_CODE_ARG_HELP: &str = "Error code from CLI stderr (example: `E102`).";
const ERROR_FORMAT_ARG_HELP: &str = "Error output format (`text` or `json`).";
const CLI_AFTER_HELP: &str = r#"Examples:
gloves bootstrap --profile openclaw --root ~/.openclaw/secrets --config ~/.openclaw/.gloves.toml --agents main,relationships,coder
gloves --root .openclaw/secrets init
gloves --root .openclaw/secrets secrets set service/token --generate
gloves --root .openclaw/secrets secrets get service/token --pipe-to cat
gloves --root .openclaw/secrets run --env API_KEY=gloves://shared/github-token -- env
gloves --root .openclaw/secrets exec env --env API_KEY=gloves://shared/github-token -- env
gloves --root .openclaw/secrets request prod/db --reason "run migration"
gloves --root .openclaw/secrets requests list
gloves --root .openclaw/secrets secrets grant service/token --to agent-b
gloves help requests approve
gloves help secrets set
gloves requests help approve
gloves tui --config /etc/gloves/prod.gloves.toml audit --limit 100
gloves explain E102
gloves --json requests approve 123e4567-e89b-12d3-a456-426614174000
gloves --error-format json requests approve 123e4567-e89b-12d3-a456-426614174000
Version:
gloves --version
gloves --json --version
More help:
gloves help [topic...]
gloves requests help [topic...]
gloves help vault
"#;
const SET_COMMAND_AFTER_HELP: &str = r#"Examples:
gloves secrets set service/token --generate
gloves secrets set long-lived/token --generate --ttl never
printf 'secret-value' | gloves secrets set service/token --stdin --ttl 7
Tips:
- Use `--generate` or `--stdin` for safer input handling.
- `--ttl` accepts a positive number of days or `never`.
- Omitting `--ttl` uses `defaults.secret_ttl_days` from config.
- `gloves secrets set` prints the expiry timestamp or says `never expires`.
"#;
const REQUEST_COMMAND_AFTER_HELP: &str = r#"Examples:
gloves request prod/db --reason "run migration"
gloves requests list
gloves requests approve <request-id>
"#;
const REQUESTS_COMMAND_AFTER_HELP: &str = r#"Examples:
gloves requests list
gloves requests approve <request-id>
gloves requests deny <request-id>
Aliases:
gloves req list
gloves req approve <request-id>
gloves req deny <request-id>
Tip:
Use this command group when you want noun-first navigation.
"#;
const APPROVE_COMMAND_AFTER_HELP: &str = r#"Examples:
gloves approve <request-id>
gloves --agent human-ops approve <request-id>
Find request IDs:
gloves requests list
See also:
gloves requests approve (preferred form)
"#;
const DENY_COMMAND_AFTER_HELP: &str = r#"Examples:
gloves deny <request-id>
gloves --agent human-ops deny <request-id>
Find request IDs:
gloves requests list
See also:
gloves requests deny (preferred form)
"#;
const LIST_COMMAND_AFTER_HELP: &str = r#"Examples:
gloves list
gloves list --pending
See also:
gloves requests list (alias: list only pending requests)
"#;
const GRANT_COMMAND_AFTER_HELP: &str = r#"Examples:
gloves secrets grant service/token --to agent-b
gloves --agent default-agent secrets grant service/token --to reviewer-a
Notes:
- Grant updates recipient access for an existing agent-owned secret.
- The caller must be the original creator of the secret.
"#;
const GET_COMMAND_AFTER_HELP: &str = r#"Examples:
gloves secrets get service/token
gloves secrets get service/token --pipe-to cat
Recovery:
If the secret does not exist, run `gloves list`.
"#;
const REVOKE_COMMAND_AFTER_HELP: &str = r#"Examples:
gloves secrets revoke service/token
Recovery:
Use `gloves list` to confirm the exact secret id before revoking.
"#;
const STATUS_COMMAND_AFTER_HELP: &str = r#"Examples:
gloves secrets status prod/db
gloves requests list
"#;
const HELP_COMMAND_AFTER_HELP: &str = r#"Examples:
gloves help
gloves help secrets set
gloves help requests approve
gloves secrets help get
gloves requests help approve
gloves vault help mount
Tips:
- Use command paths to drill down recursively.
- `help` works both at the top level and inside command groups.
"#;
const SECRETS_COMMAND_AFTER_HELP: &str = r#"Examples:
gloves secrets set service/token --generate
gloves secrets get service/token
gloves secrets grant service/token --to agent-b
gloves secrets revoke service/token
gloves secrets status service/token
"#;
const EXPLAIN_COMMAND_AFTER_HELP: &str = r#"Examples:
gloves explain E102
gloves explain e200
Tip:
Error codes are shown in stderr output, for example `error[E102]: ...`.
"#;
#[cfg(feature = "tui")]
const TUI_COMMAND_AFTER_HELP: &str = r#"Examples:
gloves tui
gloves tui --config /etc/gloves/prod.gloves.toml audit --limit 100
Startup:
- When a command path is provided after `gloves tui`, it is preloaded and auto-executed.
- Startup runs open in fullscreen output view.
Controls:
- Up/Down or j/k: move command tree
- Left/Right: collapse/expand command groups in command tree; change choices in field panes; in output they pan horizontally
- Shift+Left/Shift+Right: horizontal scroll for focused pane
- Mouse wheel left/right (or Shift+wheel): horizontal scroll for hovered pane
- Mouse wheel up/down: vertical scroll in command tree and output pane
- o or O: focus execution output pane
- Enter: split view cycles commands -> global flags -> command fields -> run -> commands; fullscreen keeps current pane focus
- Tab / Shift+Tab: switch panes
- f: toggle fullscreen for focused pane
- e: edit selected text field
- Space: toggle booleans
- Left/Right in field panes: change choices in split view; in fullscreen they pan horizontally
- r or F5: execute selected command with live streaming output
- Ctrl+C: cancel active run (q/Esc waits for cancellation first)
- ? : run `gloves help` for selected command in output pane
- / : filter command tree
- Home or g: jump output to top and disable follow-tail
- End or G: jump output to tail and re-enable follow-tail
- c: clear output history cards
- Esc: exit fullscreen and return focus to command tree; in split view it quits
- q: quit
"#;
const GPG_COMMAND_AFTER_HELP: &str = r#"Examples:
gloves --agent agent-main gpg create
gloves --agent agent-main gpg fingerprint
"#;
const SET_IDENTITY_COMMAND_AFTER_HELP: &str = r#"Examples:
gloves set-identity --agent devy
gloves set-identity --agent devy --force
"#;
const BOOTSTRAP_COMMAND_AFTER_HELP: &str = r#"Examples:
gloves bootstrap --profile openclaw --root ~/.openclaw/secrets --config ~/.openclaw/.gloves.toml --agents main,relationships,coder
gloves bootstrap --profile openclaw --root ~/.openclaw/secrets --agents main,coder --force
This command is intentionally thin:
- initializes the existing runtime layout
- creates agent age identities and recipients files
- writes `.gloves.toml` and `store/.gloves.yaml`
- validates config and verifies runtime state
It does not migrate secrets, patch OpenClaw config files, mutate Docker, or bootstrap GPG by default.
"#;
const OPENCLAW_BOOTSTRAP_COMMAND_AFTER_HELP: &str = r#"Examples:
gloves openclaw bootstrap --agents main,relationships,coder --root ~/.openclaw/secrets --config ~/.openclaw/.gloves.toml
"#;
const OPENCLAW_DOCTOR_COMMAND_AFTER_HELP: &str = r#"Examples:
gloves doctor openclaw
"#;
const INTEGRATION_COMMAND_AFTER_HELP: &str = r#"Examples:
gloves integration github list-refs
gloves integration github test token --profile work
gloves integration github rotate token --profile personal --generate
"#;
const TOP_LEVEL_SET_COMMAND_AFTER_HELP: &str = r#"Examples:
gloves --agent devy set agents/devy/api-keys/anthropic --stdin
gloves --agent main set shared/database-url --value postgres://localhost
"#;
const TOP_LEVEL_GET_COMMAND_AFTER_HELP: &str = r#"Examples:
gloves --agent devy get agents/devy/api-keys/anthropic --format raw
gloves --agent devy get agents/devy/api-keys/anthropic --format json
"#;
const RUN_COMMAND_AFTER_HELP: &str = r#"Examples:
gloves run --env API_KEY=gloves://shared/github-token -- curl -H "Authorization: Bearer $API_KEY" https://api.example.com
gloves run --env DB_URL=gloves://shared/db-url -- ./migrate.sh
Comparable tools:
- `op run`
- `doppler run`
- `aws-vault exec`
Use `gloves run` for the generic secret-aware execution UX.
Use `gloves exec env` when you want the lower-level env-delivery mechanic directly.
Use `gloves vault exec` when you specifically need the mount / execute / unmount workflow.
"#;
const EXEC_COMMAND_AFTER_HELP: &str = r#"Examples:
gloves exec env --env API_KEY=gloves://shared/github-token -- env
Use `gloves exec` when you want to select a specific delivery mechanism directly.
"#;
const EXEC_ENV_COMMAND_AFTER_HELP: &str = r#"Examples:
gloves exec env --env API_KEY=gloves://shared/github-token -- env
gloves exec env --env DB_URL=gloves://shared/db-url -- ./migrate.sh
This command is the explicit env-delivery primitive behind `gloves run`.
"#;
const SHOW_COMMAND_AFTER_HELP: &str = r#"Examples:
gloves show agents/devy/api-keys/anthropic --redacted
gloves show agents/devy/api-keys/anthropic --format json
"#;
const UPDATEKEYS_COMMAND_AFTER_HELP: &str = r#"Examples:
gloves updatekeys
gloves updatekeys --path shared --dry-run
"#;
const ROTATE_COMMAND_AFTER_HELP: &str = r#"Examples:
gloves rotate --agent devy
gloves rotate --agent devy --keep-old
"#;
const GPG_CREATE_COMMAND_AFTER_HELP: &str = r#"Examples:
gloves --agent agent-main gpg create
Notes:
- Creates a key only when missing (idempotent).
"#;
const GPG_FINGERPRINT_COMMAND_AFTER_HELP: &str = r#"Examples:
gloves --agent agent-main gpg fingerprint
Recovery:
If no key exists yet, run `gloves --agent <id> gpg create` first.
"#;
#[derive(Debug, Parser)]
#[command(
name = "gloves",
version,
about = "Secure secrets control plane for OpenClaw and multi-agent runtimes.",
after_help = CLI_AFTER_HELP,
infer_subcommands = true,
disable_help_subcommand = true,
arg_required_else_help = true
)]
pub struct Cli {
#[arg(long, global = true)]
pub root: Option<PathBuf>,
#[arg(long, global = true)]
pub agent: Option<String>,
#[arg(long, global = true)]
pub config: Option<PathBuf>,
#[arg(long, global = true)]
pub no_config: bool,
#[arg(long, value_enum, global = true)]
pub vault_mode: Option<VaultModeArg>,
#[arg(
long,
value_enum,
global = true,
default_value_t = ErrorFormatArg::Text,
help = ERROR_FORMAT_ARG_HELP
)]
pub error_format: ErrorFormatArg,
#[arg(long, global = true)]
pub json: bool,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
Init,
#[command(after_help = BOOTSTRAP_COMMAND_AFTER_HELP)]
Bootstrap {
#[arg(long, value_enum)]
profile: BootstrapProfileArg,
#[arg(long, value_name = "AGENT_LIST")]
agents: String,
#[arg(long)]
default_agent: Option<String>,
#[arg(long)]
force: bool,
},
Openclaw {
#[command(subcommand)]
command: OpenclawCommand,
},
Doctor {
#[command(subcommand)]
command: DoctorCommand,
},
#[command(after_help = INTEGRATION_COMMAND_AFTER_HELP)]
Integration {
name: String,
#[command(subcommand)]
command: IntegrationCommand,
},
#[command(after_help = SET_IDENTITY_COMMAND_AFTER_HELP)]
SetIdentity {
#[arg(long)]
agent: String,
#[arg(long)]
force: bool,
#[arg(long, hide = true)]
post_quantum: bool,
},
#[command(after_help = TOP_LEVEL_SET_COMMAND_AFTER_HELP)]
Set {
#[arg(help = SECRET_NAME_ARG_HELP)]
path: String,
#[arg(long)]
value: Option<String>,
#[arg(long)]
stdin: bool,
},
#[command(after_help = TOP_LEVEL_GET_COMMAND_AFTER_HELP)]
Get {
#[arg(help = SECRET_NAME_ARG_HELP)]
path: String,
#[arg(long, value_enum, default_value_t = SecretReadFormatArg::Raw)]
format: SecretReadFormatArg,
},
#[command(after_help = RUN_COMMAND_AFTER_HELP)]
Run {
#[arg(long = "env", value_name = "BINDING", action = clap::ArgAction::Append)]
env: Vec<String>,
#[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)]
command: Vec<String>,
},
#[command(after_help = EXEC_COMMAND_AFTER_HELP)]
Exec {
#[command(subcommand)]
command: ExecCommand,
},
#[command(after_help = SHOW_COMMAND_AFTER_HELP)]
Show {
#[arg(help = SECRET_NAME_ARG_HELP)]
path: String,
#[arg(long, default_value_t = true)]
redacted: bool,
#[arg(long, value_enum, default_value_t = SecretShowFormatArg::Text)]
format: SecretShowFormatArg,
},
#[command(after_help = UPDATEKEYS_COMMAND_AFTER_HELP)]
Updatekeys {
#[arg(long)]
path: Option<String>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
identity: Option<PathBuf>,
},
#[command(after_help = ROTATE_COMMAND_AFTER_HELP)]
Rotate {
#[arg(long)]
agent: String,
#[arg(long)]
keep_old: bool,
},
#[command(after_help = EXPLAIN_COMMAND_AFTER_HELP)]
Explain {
#[arg(help = ERROR_CODE_ARG_HELP)]
code: String,
},
#[cfg(feature = "tui")]
#[command(visible_alias = "ui", after_help = TUI_COMMAND_AFTER_HELP)]
Tui {
#[arg(
value_name = "ARGS",
num_args = 0..,
trailing_var_arg = true,
allow_hyphen_values = true
)]
args: Vec<String>,
},
#[command(after_help = HELP_COMMAND_AFTER_HELP)]
Help {
#[arg(value_name = "TOPIC", num_args = 0..)]
topic: Vec<String>,
},
#[command(after_help = SECRETS_COMMAND_AFTER_HELP)]
Secrets {
#[command(subcommand)]
command: SecretsCommand,
},
Env {
#[arg(help = SECRET_NAME_ARG_HELP)]
name: String,
var: String,
},
#[command(after_help = REQUEST_COMMAND_AFTER_HELP)]
Request {
#[arg(help = SECRET_NAME_ARG_HELP)]
name: String,
#[arg(long)]
reason: String,
#[arg(long)]
allowlist: Option<String>,
#[arg(long)]
blocklist: Option<String>,
},
#[command(visible_alias = "req", after_help = REQUESTS_COMMAND_AFTER_HELP)]
Requests {
#[command(subcommand)]
command: RequestsCommand,
},
#[command(hide = true, after_help = APPROVE_COMMAND_AFTER_HELP)]
Approve {
#[arg(help = REQUEST_ID_ARG_HELP)]
request_id: String,
},
#[command(hide = true, after_help = DENY_COMMAND_AFTER_HELP)]
Deny {
#[arg(help = REQUEST_ID_ARG_HELP)]
request_id: String,
},
#[command(visible_alias = "ls", after_help = LIST_COMMAND_AFTER_HELP)]
List {
#[arg(long)]
pending: bool,
},
Audit {
#[arg(long, default_value_t = 50)]
limit: usize,
},
Verify,
Daemon {
#[arg(long)]
bind: Option<String>,
#[arg(long)]
check: bool,
#[arg(long, hide = true, default_value_t = 0)]
max_requests: usize,
},
Vault {
#[command(subcommand)]
command: VaultCommand,
},
Config {
#[command(subcommand)]
command: ConfigCommand,
},
Access {
#[command(subcommand)]
command: AccessCommand,
},
#[command(after_help = GPG_COMMAND_AFTER_HELP)]
Gpg {
#[command(subcommand)]
command: GpgCommand,
},
#[command(hide = true)]
ExtpassGet {
#[arg(help = SECRET_NAME_ARG_HELP)]
name: String,
},
}
#[derive(Debug, Clone, ValueEnum)]
pub enum VaultOwnerArg {
Agent,
Human,
}
impl From<VaultOwnerArg> for Owner {
fn from(value: VaultOwnerArg) -> Self {
match value {
VaultOwnerArg::Agent => Owner::Agent,
VaultOwnerArg::Human => Owner::Human,
}
}
}
#[derive(Debug, Clone, ValueEnum)]
pub enum VaultModeArg {
Auto,
Required,
Disabled,
}
impl From<VaultModeArg> for VaultMode {
fn from(value: VaultModeArg) -> Self {
match value {
VaultModeArg::Auto => VaultMode::Auto,
VaultModeArg::Required => VaultMode::Required,
VaultModeArg::Disabled => VaultMode::Disabled,
}
}
}
#[derive(Debug, Clone, ValueEnum)]
pub enum BootstrapProfileArg {
Openclaw,
}
#[derive(Debug, Subcommand)]
#[command(disable_help_subcommand = true)]
pub enum OpenclawCommand {
#[command(after_help = OPENCLAW_BOOTSTRAP_COMMAND_AFTER_HELP)]
Bootstrap {
#[arg(long, value_name = "AGENT_LIST")]
agents: String,
#[arg(long)]
default_agent: Option<String>,
#[arg(long)]
force: bool,
},
Bridge {
#[command(subcommand)]
command: OpenclawBridgeCommand,
},
}
#[derive(Debug, Subcommand)]
#[command(disable_help_subcommand = true)]
pub enum OpenclawBridgeCommand {
Install,
Start,
Stop,
Status,
Run,
}
#[derive(Debug, Subcommand)]
#[command(disable_help_subcommand = true)]
pub enum DoctorCommand {
#[command(after_help = OPENCLAW_DOCTOR_COMMAND_AFTER_HELP)]
Openclaw,
}
#[derive(Debug, Subcommand)]
#[command(disable_help_subcommand = true)]
pub enum IntegrationCommand {
ListRefs,
Test {
slot: String,
#[arg(long)]
profile: Option<String>,
},
Rotate {
slot: String,
#[arg(long)]
profile: Option<String>,
#[arg(long)]
generate: bool,
#[arg(long)]
value: Option<String>,
#[arg(long)]
stdin: bool,
#[arg(long)]
ttl: Option<String>,
},
}
#[derive(Debug, Subcommand)]
#[command(disable_help_subcommand = true)]
pub enum VaultCommand {
Help {
#[arg(value_name = "TOPIC", num_args = 0..)]
topic: Vec<String>,
},
Init {
name: String,
#[arg(long, value_enum)]
owner: VaultOwnerArg,
},
Mount {
name: String,
#[arg(long)]
ttl: Option<String>,
#[arg(long)]
mountpoint: Option<PathBuf>,
#[arg(long)]
agent: Option<String>,
},
Exec {
name: String,
#[arg(long)]
ttl: Option<String>,
#[arg(long)]
mountpoint: Option<PathBuf>,
#[arg(long)]
agent: Option<String>,
#[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)]
command: Vec<String>,
},
Unmount {
name: String,
#[arg(long)]
agent: Option<String>,
},
Status,
List,
AskFile {
name: String,
#[arg(long)]
file: String,
#[arg(long)]
requester: Option<String>,
#[arg(long)]
trusted_agent: String,
#[arg(long)]
reason: Option<String>,
},
}
#[derive(Debug, Subcommand)]
#[command(disable_help_subcommand = true)]
pub enum ExecCommand {
#[command(after_help = EXEC_ENV_COMMAND_AFTER_HELP)]
Env {
#[arg(long = "env", value_name = "BINDING", action = clap::ArgAction::Append)]
env: Vec<String>,
#[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)]
command: Vec<String>,
},
}
#[derive(Debug, Subcommand)]
#[command(disable_help_subcommand = true)]
pub enum SecretsCommand {
Help {
#[arg(value_name = "TOPIC", num_args = 0..)]
topic: Vec<String>,
},
#[command(after_help = SET_COMMAND_AFTER_HELP)]
Set {
#[arg(help = SECRET_NAME_ARG_HELP)]
name: String,
#[arg(long)]
generate: bool,
#[arg(long)]
value: Option<String>,
#[arg(long)]
stdin: bool,
#[arg(long)]
ttl: Option<String>,
},
#[command(after_help = GET_COMMAND_AFTER_HELP)]
Get {
#[arg(help = SECRET_NAME_ARG_HELP)]
name: String,
#[arg(long, conflicts_with = "pipe_to_args")]
pipe_to: Option<String>,
#[arg(long, conflicts_with = "pipe_to")]
pipe_to_args: Option<String>,
},
#[command(after_help = GRANT_COMMAND_AFTER_HELP)]
Grant {
#[arg(help = SECRET_NAME_ARG_HELP)]
name: String,
#[arg(long)]
to: String,
},
#[command(after_help = REVOKE_COMMAND_AFTER_HELP)]
Revoke {
#[arg(help = SECRET_NAME_ARG_HELP)]
name: String,
},
#[command(after_help = STATUS_COMMAND_AFTER_HELP)]
Status {
#[arg(help = SECRET_NAME_ARG_HELP)]
name: String,
},
}
#[derive(Debug, Subcommand)]
#[command(disable_help_subcommand = true)]
pub enum ConfigCommand {
Help {
#[arg(value_name = "TOPIC", num_args = 0..)]
topic: Vec<String>,
},
Validate,
}
#[derive(Debug, Subcommand)]
#[command(disable_help_subcommand = true)]
pub enum AccessCommand {
Help {
#[arg(value_name = "TOPIC", num_args = 0..)]
topic: Vec<String>,
},
Paths {
#[arg(long)]
agent: String,
},
}
#[derive(Debug, Subcommand)]
#[command(disable_help_subcommand = true)]
pub enum RequestsCommand {
Help {
#[arg(value_name = "TOPIC", num_args = 0..)]
topic: Vec<String>,
},
#[command(
after_help = "Examples:\n gloves requests list\n gloves req list\n\nSee also:\n gloves list (shows secrets and pending requests)\n"
)]
List,
#[command(after_help = APPROVE_COMMAND_AFTER_HELP)]
Approve {
#[arg(help = REQUEST_ID_ARG_HELP)]
request_id: String,
},
#[command(after_help = DENY_COMMAND_AFTER_HELP)]
Deny {
#[arg(help = REQUEST_ID_ARG_HELP)]
request_id: String,
},
}
#[derive(Debug, Clone, Copy, ValueEnum, Eq, PartialEq)]
pub enum ErrorFormatArg {
Text,
Json,
}
#[derive(Debug, Clone, Copy, ValueEnum, Eq, PartialEq)]
pub enum SecretReadFormatArg {
Raw,
Json,
}
#[derive(Debug, Clone, Copy, ValueEnum, Eq, PartialEq)]
pub enum SecretShowFormatArg {
Text,
Json,
}
#[derive(Debug, Subcommand)]
#[command(disable_help_subcommand = true)]
pub enum GpgCommand {
Help {
#[arg(value_name = "TOPIC", num_args = 0..)]
topic: Vec<String>,
},
#[command(after_help = GPG_CREATE_COMMAND_AFTER_HELP)]
Create,
#[command(after_help = GPG_FINGERPRINT_COMMAND_AFTER_HELP)]
Fingerprint,
}
pub fn run(cli: Cli) -> Result<i32> {
commands::run(cli)
}
pub fn emit_version_output(json: bool) -> Result<i32> {
commands::emit_version_output(json)
}
#[allow(dead_code)]
fn ttl_seconds(ttl: Duration) -> i64 {
ttl.num_seconds().max(DEFAULT_TTL_SECONDS)
}
#[cfg(test)]
mod unit_tests {
use super::{
runtime::{
load_or_create_identity_for_agent, load_or_create_signing_key_for_agent,
parse_secret_ttl_argument, validate_ttl_days, SecretTtl,
},
secret_input::{parse_duration_value, resolve_secret_input},
ttl_seconds, BootstrapProfileArg, Cli, Command, ErrorFormatArg, ExecCommand,
RequestsCommand, SecretReadFormatArg, SecretShowFormatArg, SecretsCommand,
};
use crate::error::GlovesError;
use crate::paths::SecretsPaths;
use crate::types::AgentId;
use chrono::Duration;
use clap::{error::ErrorKind, CommandFactory, Parser};
use std::path::PathBuf;
#[test]
fn resolve_secret_input_generate_ok() {
let bytes = resolve_secret_input(true, None, false).unwrap();
assert!(!bytes.is_empty());
}
#[test]
fn resolve_secret_input_generate_conflict() {
assert!(matches!(
resolve_secret_input(true, Some("abc".to_owned()), false),
Err(GlovesError::InvalidInput(_))
));
}
#[test]
fn resolve_secret_input_value_ok() {
let bytes = resolve_secret_input(false, Some("abc".to_owned()), false).unwrap();
assert_eq!(bytes, b"abc");
}
#[test]
fn resolve_secret_input_empty_value_rejected() {
assert!(matches!(
resolve_secret_input(false, Some(String::new()), false),
Err(GlovesError::InvalidInput(_))
));
}
#[test]
fn resolve_secret_input_requires_source() {
assert!(matches!(
resolve_secret_input(false, None, false),
Err(GlovesError::InvalidInput(_))
));
}
#[test]
fn ttl_seconds_enforces_default_floor() {
let below_floor = ttl_seconds(Duration::seconds(1));
assert!(below_floor >= 86_400);
}
#[test]
fn validate_ttl_days_rejects_non_positive_values() {
assert!(matches!(
validate_ttl_days(0, "--ttl"),
Err(GlovesError::InvalidInput(_))
));
assert!(matches!(
validate_ttl_days(-1, "--ttl"),
Err(GlovesError::InvalidInput(_))
));
}
#[test]
fn validate_ttl_days_accepts_positive_value() {
let ttl_days = validate_ttl_days(7, "--ttl").unwrap();
assert_eq!(ttl_days, 7);
}
#[test]
fn parse_secret_ttl_argument_accepts_never() {
let ttl = parse_secret_ttl_argument(Some("never"), 30, "--ttl").unwrap();
assert_eq!(ttl, SecretTtl::Never);
}
#[test]
fn parse_secret_ttl_argument_uses_default_days_when_omitted() {
let ttl = parse_secret_ttl_argument(None, 30, "--ttl").unwrap();
assert_eq!(ttl, SecretTtl::Days(30));
}
#[test]
fn parse_duration_value_accepts_hours() {
let duration = parse_duration_value("2h", "--ttl").unwrap();
assert_eq!(duration, Duration::hours(2));
}
#[test]
fn parse_duration_value_rejects_invalid_units() {
assert!(matches!(
parse_duration_value("2w", "--ttl"),
Err(GlovesError::InvalidInput(_))
));
}
#[test]
fn parse_duration_value_rejects_non_positive_values() {
assert!(matches!(
parse_duration_value("0h", "--ttl"),
Err(GlovesError::InvalidInput(_))
));
}
#[test]
fn load_or_create_identity_for_agent_rejects_invalid_file() {
let temp_dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(temp_dir.path().join("agents").join("default-agent")).unwrap();
std::fs::write(
temp_dir
.path()
.join("agents")
.join("default-agent")
.join("age.key"),
"invalid",
)
.unwrap();
let paths = SecretsPaths::new(temp_dir.path());
let agent_id = AgentId::new("default-agent").unwrap();
assert!(load_or_create_identity_for_agent(&paths, &agent_id).is_err());
}
#[test]
fn load_or_create_signing_key_for_agent_rejects_invalid_file() {
let temp_dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(temp_dir.path().join("agents").join("default-agent")).unwrap();
std::fs::write(
temp_dir
.path()
.join("agents")
.join("default-agent")
.join("signing.key"),
[1_u8; 8],
)
.unwrap();
let paths = SecretsPaths::new(temp_dir.path());
let agent_id = AgentId::new("default-agent").unwrap();
assert!(matches!(
load_or_create_signing_key_for_agent(&paths, &agent_id),
Err(GlovesError::InvalidInput(_))
));
}
#[test]
fn load_or_create_identity_for_agent_uses_agent_specific_file() {
let temp_dir = tempfile::tempdir().unwrap();
let paths = SecretsPaths::new(temp_dir.path());
let agent_id = AgentId::new("agent-main").unwrap();
let identity_file = load_or_create_identity_for_agent(&paths, &agent_id).unwrap();
assert!(identity_file.ends_with("agents/agent-main/age.key"));
assert!(identity_file.exists());
assert!(!paths.default_identity_file().exists());
}
#[test]
fn load_or_create_signing_key_for_agent_uses_agent_specific_file() {
let temp_dir = tempfile::tempdir().unwrap();
let paths = SecretsPaths::new(temp_dir.path());
let agent_id = AgentId::new("agent-main").unwrap();
let key = load_or_create_signing_key_for_agent(&paths, &agent_id).unwrap();
assert!(temp_dir
.path()
.join("agents")
.join("agent-main")
.join("signing.key")
.exists());
assert!(!paths.default_signing_key_file().exists());
assert_eq!(key.to_bytes().len(), 32);
}
#[test]
fn cli_help_includes_examples_and_help_hint() {
let mut command = Cli::command();
let help = command.render_long_help().to_string();
assert!(help.contains("Examples:"));
assert!(help.contains("gloves --version"));
assert!(help.contains("gloves --json --version"));
assert!(help.contains("gloves help [topic...]"));
assert!(help.contains("--error-format"));
}
#[test]
fn cli_version_flag_is_available() {
let error = Cli::try_parse_from(["gloves", "--version"]).unwrap_err();
assert_eq!(error.kind(), ErrorKind::DisplayVersion);
}
#[test]
fn cli_short_version_flag_is_available() {
let error = Cli::try_parse_from(["gloves", "-V"]).unwrap_err();
assert_eq!(error.kind(), ErrorKind::DisplayVersion);
}
#[test]
fn cli_approve_help_includes_request_lookup_example() {
let cli = Cli::try_parse_from(["gloves", "help", "approve"]).unwrap();
assert!(matches!(
cli.command,
Command::Help { topic } if topic == vec!["approve".to_owned()]
));
}
#[test]
fn cli_set_help_includes_input_examples() {
let cli = Cli::try_parse_from(["gloves", "help", "secrets", "set"]).unwrap();
assert!(matches!(
cli.command,
Command::Help { topic } if topic == vec!["secrets".to_owned(), "set".to_owned()]
));
}
#[test]
fn cli_secrets_set_parses_to_nested_command() {
let cli = Cli::try_parse_from([
"gloves",
"secrets",
"set",
"service/token",
"--generate",
"--ttl",
"1",
])
.unwrap();
assert!(matches!(
cli.command,
Command::Secrets {
command: SecretsCommand::Set { name, generate, ttl, .. }
} if name == "service/token" && generate && ttl.as_deref() == Some("1")
));
}
#[test]
fn cli_top_level_set_parses_namespaced_path() {
let cli =
Cli::try_parse_from(["gloves", "set", "agents/devy/api-keys/anthropic", "--stdin"])
.unwrap();
assert!(matches!(
cli.command,
Command::Set { path, stdin, value }
if path == "agents/devy/api-keys/anthropic" && stdin && value.is_none()
));
}
#[test]
fn cli_top_level_get_accepts_format_flag() {
let cli = Cli::try_parse_from([
"gloves",
"get",
"agents/devy/api-keys/anthropic",
"--format",
"json",
])
.unwrap();
assert!(matches!(
cli.command,
Command::Get { path, format }
if path == "agents/devy/api-keys/anthropic"
&& format == SecretReadFormatArg::Json
));
}
#[test]
fn cli_run_accepts_repeated_env_secret_ref_bindings() {
let cli = Cli::try_parse_from([
"gloves",
"run",
"--env",
"API_KEY=gloves://shared/github-token",
"--env",
"DB_URL=gloves://shared/db-url",
"--",
"sh",
"-c",
"env",
])
.unwrap();
assert!(matches!(
cli.command,
Command::Run {
env,
command,
} if env
== vec![
"API_KEY=gloves://shared/github-token".to_owned(),
"DB_URL=gloves://shared/db-url".to_owned()
] && command == vec![
"sh".to_owned(),
"-c".to_owned(),
"env".to_owned()
]
));
}
#[test]
fn cli_bootstrap_accepts_openclaw_profile_arguments() {
let cli = Cli::try_parse_from([
"gloves",
"bootstrap",
"--profile",
"openclaw",
"--root",
"~/.openclaw/secrets",
"--config",
"~/.openclaw/.gloves.toml",
"--agents",
"main,relationships,coder",
"--default-agent",
"main",
"--force",
])
.unwrap();
assert!(matches!(
cli.command,
Command::Bootstrap {
profile: BootstrapProfileArg::Openclaw,
agents,
default_agent,
force,
} if agents == "main,relationships,coder"
&& default_agent == Some("main".to_owned())
&& force
));
assert_eq!(cli.root, Some(PathBuf::from("~/.openclaw/secrets")));
assert_eq!(cli.config, Some(PathBuf::from("~/.openclaw/.gloves.toml")));
}
#[test]
fn cli_exec_env_accepts_repeated_env_secret_ref_bindings() {
let cli = Cli::try_parse_from([
"gloves",
"exec",
"env",
"--env",
"API_KEY=gloves://shared/github-token",
"--",
"sh",
"-c",
"env",
])
.unwrap();
assert!(matches!(
cli.command,
Command::Exec {
command: ExecCommand::Env { env, command }
} if env == vec!["API_KEY=gloves://shared/github-token".to_owned()]
&& command == vec!["sh".to_owned(), "-c".to_owned(), "env".to_owned()]
));
}
#[test]
fn cli_show_accepts_json_format_flag() {
let cli = Cli::try_parse_from([
"gloves",
"show",
"agents/devy/api-keys/anthropic",
"--format",
"json",
])
.unwrap();
assert!(matches!(
cli.command,
Command::Show { path, format, .. }
if path == "agents/devy/api-keys/anthropic"
&& format == SecretShowFormatArg::Json
));
}
#[test]
fn cli_set_identity_requires_agent() {
let cli = Cli::try_parse_from(["gloves", "set-identity", "--agent", "devy"]).unwrap();
assert!(matches!(
cli.command,
Command::SetIdentity { agent, force, .. } if agent == "devy" && !force
));
}
#[test]
fn cli_requests_alias_parses_to_requests_command() {
let cli = Cli::try_parse_from(["gloves", "req", "list"]).unwrap();
assert!(matches!(
cli.command,
Command::Requests {
command: RequestsCommand::List
}
));
}
#[test]
fn cli_list_alias_parses_to_list_command() {
let cli = Cli::try_parse_from(["gloves", "ls"]).unwrap();
assert!(matches!(cli.command, Command::List { pending: false }));
}
#[test]
fn cli_grant_parses_to_secrets_grant_command() {
let cli = Cli::try_parse_from([
"gloves",
"secrets",
"grant",
"service/token",
"--to",
"agent-b",
])
.unwrap();
assert!(matches!(
cli.command,
Command::Secrets {
command: SecretsCommand::Grant { name, to }
} if name == "service/token" && to == "agent-b"
));
}
#[test]
fn cli_secrets_help_subcommand_parses_nested_topic() {
let cli = Cli::try_parse_from(["gloves", "secrets", "help", "get"]).unwrap();
assert!(matches!(
cli.command,
Command::Secrets {
command: SecretsCommand::Help { topic }
} if topic == vec!["get".to_owned()]
));
}
#[test]
fn cli_version_subcommand_is_not_supported() {
let error = Cli::try_parse_from(["gloves", "version"]).unwrap_err();
assert_eq!(error.kind(), ErrorKind::InvalidSubcommand);
}
#[test]
fn cli_explain_help_mentions_error_codes() {
let cli = Cli::try_parse_from(["gloves", "help", "explain"]).unwrap();
assert!(matches!(
cli.command,
Command::Help { topic } if topic == vec!["explain".to_owned()]
));
}
#[test]
fn cli_help_parses_recursive_topic_path() {
let cli = Cli::try_parse_from(["gloves", "help", "requests", "approve"]).unwrap();
assert!(matches!(
cli.command,
Command::Help { topic }
if topic == vec!["requests".to_owned(), "approve".to_owned()]
));
}
#[test]
fn cli_requests_help_subcommand_parses_nested_topic() {
let cli = Cli::try_parse_from(["gloves", "requests", "help", "approve"]).unwrap();
assert!(matches!(
cli.command,
Command::Requests {
command: RequestsCommand::Help { topic }
} if topic == vec!["approve".to_owned()]
));
}
#[test]
fn cli_json_flag_defaults_to_false() {
let cli = Cli::try_parse_from(["gloves", "init"]).unwrap();
assert!(!cli.json);
}
#[test]
fn cli_json_flag_alias_is_available() {
let cli = Cli::try_parse_from(["gloves", "--json", "init"]).unwrap();
assert!(cli.json);
}
#[test]
fn cli_error_format_defaults_to_text() {
let cli = Cli::try_parse_from(["gloves", "init"]).unwrap();
assert_eq!(cli.error_format, ErrorFormatArg::Text);
}
#[test]
fn cli_error_format_accepts_json() {
let cli = Cli::try_parse_from(["gloves", "--error-format", "json", "init"]).unwrap();
assert_eq!(cli.error_format, ErrorFormatArg::Json);
}
#[cfg(feature = "tui")]
#[test]
fn cli_tui_accepts_trailing_bootstrap_args() {
let cli = Cli::try_parse_from([
"gloves",
"tui",
"--config",
"/etc/gloves/prod.gloves.toml",
"audit",
"--limit",
"100",
])
.unwrap();
assert_eq!(
cli.config,
Some(PathBuf::from("/etc/gloves/prod.gloves.toml"))
);
assert!(matches!(
cli.command,
Command::Tui { args }
if args
== vec![
"audit".to_owned(),
"--limit".to_owned(),
"100".to_owned()
]
));
}
}