use clap::{Args, Subcommand, ValueEnum};
const DEFAULT_CLAIM_TIMEOUT: &str = "15m";
pub const DEFAULT_CLAIM_WEB_ORIGIN: &str = "https://app.heddle.sh";
#[derive(Args, Clone, Debug)]
#[command(after_help = "\
Promote moves a personal hosted spool to the shared root namespace:
spool/<your-handle>/<name> → spool/<name>
The server requires the root slug to be free, a claimed/verified account, and
an owner grant on the personal spool. Denials print the recovery step.
Examples:
heddle promote spool/willow-ibis-8e7264/notes
heddle promote willow-ibis-8e7264/notes --server api.preview.heddle.sh
heddle promote https://api.preview.heddle.sh/willow-ibis-8e7264/notes
")]
pub struct PromoteArgs {
#[arg(value_name = "PATH")]
pub path: String,
#[command(flatten)]
pub server: super::HostedServerArgs,
}
#[derive(Args, Clone, Debug)]
pub struct ClaimArgs {
#[arg(long)]
pub server: Option<String>,
#[arg(long, value_name = "ORIGIN")]
pub web_origin: Option<String>,
#[arg(
long,
default_value = DEFAULT_CLAIM_TIMEOUT,
value_name = "DURATION",
value_parser = parse_claim_timeout
)]
pub timeout: std::time::Duration,
}
fn parse_claim_timeout(value: &str) -> Result<std::time::Duration, String> {
let value = value.trim();
let (amount, multiplier) = match value.as_bytes().last().copied() {
Some(b's') => (&value[..value.len() - 1], 1),
Some(b'm') => (&value[..value.len() - 1], 60),
Some(b'h') => (&value[..value.len() - 1], 60 * 60),
Some(b'd') => (&value[..value.len() - 1], 24 * 60 * 60),
Some(byte) if byte.is_ascii_digit() => (value, 1),
_ => {
return Err("expected a positive duration such as 30s, 15m, 2h, or 1d".to_string());
}
};
let amount = amount
.parse::<u64>()
.map_err(|_| "claim timeout must be a positive whole number".to_string())?;
let seconds = amount
.checked_mul(multiplier)
.ok_or_else(|| "claim timeout is too large".to_string())?;
if seconds == 0 {
return Err("claim timeout must be greater than zero".to_string());
}
Ok(std::time::Duration::from_secs(seconds))
}
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
pub enum AgentTemplateArg {
Reviewer,
Contributor,
#[value(name = "ci-landing")]
CiLanding,
}
#[derive(Subcommand, Clone, Debug)]
pub enum AuthCommands {
Login {
#[arg(long)]
server: Option<String>,
#[arg(long)]
open_browser: bool,
#[arg(
long,
value_name = "WEB_HOST",
value_parser = config::web_origin::PairingWebOrigin::parse,
conflicts_with_all = ["credential", "invite"],
)]
host: Option<config::web_origin::PairingWebOrigin>,
#[arg(long, conflicts_with = "credential")]
invite: Option<String>,
#[arg(long, value_name = "HCRED_PATH", conflicts_with_all = ["server", "open_browser", "invite"])]
credential: Option<std::path::PathBuf>,
},
Logout {
#[arg(long)]
server: Option<String>,
},
Status {
#[arg(long)]
server: Option<String>,
},
#[command(args_conflicts_with_subcommands = true)]
#[command(after_help = "\
Signup-only. `heddle auth invite` creates an account-creation code.
It does not grant spool access. Add a collaborator with:
heddle grant create --spool <path|url> --principal <handle> --role writer
")]
Invite {
#[arg(long)]
email: Option<String>,
#[arg(long, global = true)]
server: Option<String>,
#[command(subcommand)]
command: Option<AuthInviteCommands>,
},
Trust {
#[command(subcommand)]
command: AuthTrustCommands,
},
DeriveAgent {
#[arg(long)]
server: String,
#[arg(long)]
agent_id: Option<String>,
#[arg(long = "ttl", default_value_t = 3600)]
ttl_secs: u64,
#[arg(long = "scope")]
scopes: Vec<String>,
#[arg(long = "allow")]
allowed_operations: Vec<String>,
#[arg(long, value_enum)]
template: Option<AgentTemplateArg>,
#[arg(long, conflicts_with_all = ["template", "allowed_operations"])]
runner: bool,
#[arg(long, value_name = "HCRED_PATH")]
out: Option<std::path::PathBuf>,
},
CreateServiceToken {
name: String,
#[arg(long)]
scope: String,
#[arg(long)]
server: Option<String>,
#[arg(long, value_name = "HCRED_PATH")]
out: Option<std::path::PathBuf>,
},
}
#[derive(Subcommand, Clone, Debug)]
pub enum AuthInviteCommands {
List,
}
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
pub enum GrantRoleArg {
Reader,
Writer,
Administrator,
}
impl GrantRoleArg {
pub fn as_resource_role_name(self) -> &'static str {
match self {
Self::Reader => "reader",
Self::Writer => "writer",
Self::Administrator => "administrator",
}
}
}
#[derive(Subcommand, Clone, Debug)]
pub enum GrantCommands {
#[command(after_help = "\
Adds a collaborator to an existing hosted spool. This is not a signup
invite — `heddle auth invite` only creates an account-creation code.
Roles: reader, writer, administrator. A delegate can grant only authority
they currently hold; owner-only cryptographic powers are separate.
Examples:
heddle grant create --spool spool/willow-ibis-8e7264/notes --principal alice --role writer
heddle grant create --spool https://api.preview.heddle.sh/notes --principal alice --role reader
")]
Create(GrantCreateArgs),
#[command(after_help = "\
`--spool` is the same bare path create and delete use
(`spool/<handle>/<name>`, `<handle>/<name>`, or a hosted URL).
Do not prefix `repo:`.
Examples:
heddle grant list --spool spool/willow-ibis-8e7264/notes
heddle grant list --spool notes --server api.preview.heddle.sh
")]
List(GrantListArgs),
#[command(after_help = "\
The acting account or delegate must currently have grant administration on
this Spool. A grant's role does not change the authority needed to remove it.
Examples:
heddle grant delete <grant-id> --spool spool/willow-ibis-8e7264/notes
")]
Delete(GrantDeleteArgs),
}
#[derive(Args, Clone, Debug)]
pub struct GrantCreateArgs {
#[arg(long, value_name = "PATH|URL")]
pub spool: String,
#[arg(long, value_name = "HANDLE|ACCOUNT")]
pub principal: String,
#[arg(long, value_enum)]
pub role: GrantRoleArg,
#[command(flatten)]
pub server: super::HostedServerArgs,
}
#[derive(Args, Clone, Debug)]
pub struct GrantListArgs {
#[arg(long, value_name = "PATH|URL")]
pub spool: String,
#[command(flatten)]
pub server: super::HostedServerArgs,
}
#[derive(Args, Clone, Debug)]
pub struct GrantDeleteArgs {
#[arg(value_name = "GRANT_ID")]
pub id: String,
#[arg(long, value_name = "PATH|URL")]
pub spool: String,
#[command(flatten)]
pub server: super::HostedServerArgs,
}
#[derive(Subcommand, Clone, Debug)]
pub enum AuthTrustCommands {
Show(AuthTrustShowArgs),
Replace(AuthTrustReplaceArgs),
}
#[derive(Args, Clone, Debug)]
pub struct AuthTrustShowArgs {
#[arg(long)]
pub server: String,
}
#[derive(Args, Clone, Debug)]
pub struct AuthTrustReplaceArgs {
#[arg(long)]
pub server: String,
#[arg(long, value_name = "64_HEX")]
pub expect_current_public_key: String,
#[arg(long)]
pub key_id: String,
#[arg(long, value_name = "64_HEX")]
pub public_key: String,
}
#[cfg(test)]
mod tests {
use clap::Parser;
use crate::cli::{
AuthCommands, AuthInviteCommands, AuthTrustCommands, Cli, Commands, GrantCommands,
GrantRoleArg,
};
#[test]
fn trust_replace_parses_compare_and_swap_inputs() {
let old_key = "11".repeat(32);
let new_key = "22".repeat(32);
let cli = Cli::try_parse_from([
"heddle",
"auth",
"trust",
"replace",
"--server",
"api.example",
"--expect-current-public-key",
&old_key,
"--key-id",
"next-key",
"--public-key",
&new_key,
])
.expect("trust replacement parses");
let Commands::Auth {
command:
AuthCommands::Trust {
command: AuthTrustCommands::Replace(args),
},
} = cli.command
else {
panic!("expected auth trust replace");
};
assert_eq!(args.server, "api.example");
assert_eq!(args.expect_current_public_key, old_key);
assert_eq!(args.key_id, "next-key");
assert_eq!(args.public_key, new_key);
}
#[test]
fn login_parses_credential_path() {
let cli = Cli::try_parse_from([
"heddle",
"auth",
"login",
"--credential",
"/run/secrets/agent.hcred",
])
.expect("credential login flag parses");
let Commands::Auth {
command:
AuthCommands::Login {
server,
credential,
open_browser,
host,
invite,
},
} = cli.command
else {
panic!("expected auth login");
};
assert_eq!(host, None);
assert_eq!(server, None, "server comes from the credential file");
assert_eq!(
credential.as_deref(),
Some(std::path::Path::new("/run/secrets/agent.hcred"))
);
assert!(!open_browser);
assert_eq!(invite, None);
}
#[test]
fn login_credential_conflicts_with_browser_flags() {
for conflicting in [
vec![
"--credential",
"/run/secrets/agent.hcred",
"--server",
"api.heddle.sh",
],
vec!["--credential", "/run/secrets/agent.hcred", "--open-browser"],
vec![
"--credential",
"/run/secrets/agent.hcred",
"--invite",
"code",
],
] {
let mut args = vec!["heddle", "auth", "login"];
args.extend(conflicting);
assert!(
Cli::try_parse_from(args).is_err(),
"--credential must not combine with the browser-login flags"
);
}
}
#[test]
fn interactive_login_needs_no_flags() {
Cli::try_parse_from(["heddle", "auth", "login"])
.expect("interactive login may resolve the configured default server");
}
#[test]
fn claim_is_one_top_level_resident_command() {
let cli = Cli::try_parse_from([
"heddle",
"claim",
"--server",
"weft.example",
"--web-origin",
"https://heddle.example",
"--timeout",
"30m",
])
.expect("claim flags parse");
let Commands::Claim(args) = cli.command else {
panic!("expected top-level claim");
};
assert_eq!(args.server.as_deref(), Some("weft.example"));
assert_eq!(args.web_origin.as_deref(), Some("https://heddle.example"));
assert_eq!(args.timeout, std::time::Duration::from_secs(30 * 60));
assert!(Cli::try_parse_from(["heddle", "claim", "--timeout", "0s"]).is_err());
}
#[test]
fn promote_parses_path_and_optional_server() {
let cli = Cli::try_parse_from([
"heddle",
"promote",
"spool/willow-ibis-8e7264/notes",
"--server",
"api.preview.heddle.sh",
])
.expect("promote flags parse");
let Commands::Promote(args) = cli.command else {
panic!("expected top-level promote");
};
assert_eq!(args.path, "spool/willow-ibis-8e7264/notes");
assert_eq!(args.server.server.as_deref(), Some("api.preview.heddle.sh"));
assert!(Cli::try_parse_from(["heddle", "promote"]).is_err());
}
#[test]
fn login_parses_an_optional_invite() {
let cli = Cli::try_parse_from([
"heddle",
"auth",
"login",
"--server",
"api.heddle.test",
"--invite",
"invite-secret",
])
.expect("auth login --invite parses");
let Commands::Auth {
command:
AuthCommands::Login {
server,
invite,
credential,
open_browser,
host,
},
} = cli.command
else {
panic!("expected auth login");
};
assert_eq!(server.as_deref(), Some("api.heddle.test"));
assert_eq!(invite.as_deref(), Some("invite-secret"));
assert_eq!(credential, None);
assert!(!open_browser);
assert_eq!(host, None);
}
#[test]
fn login_host_is_the_web_approval_host_distinct_from_server() {
let cli = Cli::try_parse_from([
"heddle",
"auth",
"login",
"--server",
"api.staging.heddle.test",
"--host",
"PR-17-Tapestry.zephyr-forge.workers.dev",
"--open-browser",
])
.expect("auth login --host parses");
let Commands::Auth {
command: AuthCommands::Login { server, host, .. },
} = cli.command
else {
panic!("expected auth login");
};
assert_eq!(server.as_deref(), Some("api.staging.heddle.test"));
assert_eq!(
host.as_ref().map(|origin| origin.as_str()),
Some("https://pr-17-tapestry.zephyr-forge.workers.dev"),
"--host normalises to the canonical https origin"
);
}
#[test]
fn login_host_rejects_non_origin_values_locally() {
for invalid in [
"http://preview.example.dev",
"https://preview.example.dev:8443",
"https://preview.example.dev/auth",
"https://user@preview.example.dev",
"https://*.example.dev",
"localhost",
"",
] {
let error = Cli::try_parse_from(["heddle", "auth", "login", "--host", invalid])
.err()
.unwrap_or_else(|| panic!("--host {invalid:?} must be rejected"));
assert_eq!(
error.kind(),
clap::error::ErrorKind::ValueValidation,
"{invalid:?}: {error}"
);
}
}
#[test]
fn login_host_conflicts_with_paths_that_never_pair() {
for conflicting in [
vec!["--credential", "/run/secrets/agent.hcred"],
vec!["--invite", "code"],
] {
let mut args = vec!["heddle", "auth", "login", "--host", "preview.example.dev"];
args.extend(conflicting);
let error = Cli::try_parse_from(args)
.err()
.unwrap_or_else(|| panic!("--host must not combine with credential or invite"));
assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict);
}
}
#[test]
fn signup_invite_create_and_list_parse() {
let create = Cli::try_parse_from([
"heddle",
"auth",
"invite",
"--server",
"api.heddle.test",
"--email",
"alice@example.com",
])
.expect("auth invite create flags parse");
let Commands::Auth {
command:
AuthCommands::Invite {
email,
server,
command,
},
} = create.command
else {
panic!("expected auth invite");
};
assert_eq!(email.as_deref(), Some("alice@example.com"));
assert_eq!(server.as_deref(), Some("api.heddle.test"));
assert!(command.is_none());
let list = Cli::try_parse_from([
"heddle",
"auth",
"invite",
"list",
"--server",
"api.heddle.test",
])
.expect("auth invite list flags parse");
let Commands::Auth {
command:
AuthCommands::Invite {
email,
server,
command: Some(AuthInviteCommands::List),
},
} = list.command
else {
panic!("expected auth invite list");
};
assert_eq!(email, None);
assert_eq!(server.as_deref(), Some("api.heddle.test"));
assert!(
Cli::try_parse_from([
"heddle",
"auth",
"invite",
"--email",
"alice@example.com",
"list",
])
.is_err(),
"create-only --email must not be accepted by list"
);
}
#[test]
fn derive_agent_parses_repeatable_scopes_and_operation_narrowing() {
let cli = Cli::try_parse_from([
"heddle",
"auth",
"derive-agent",
"--server",
"api.heddle.test",
"--ttl",
"900",
"--scope",
"spool:acme/api",
"--scope",
"acme/tools",
"--allow",
"Push",
"--allow",
"GetState",
])
.expect("derive-agent flags parse");
let Commands::Auth {
command:
AuthCommands::DeriveAgent {
server,
ttl_secs,
scopes,
allowed_operations,
..
},
} = cli.command
else {
panic!("expected auth derive-agent");
};
assert_eq!(server, "api.heddle.test");
assert_eq!(ttl_secs, 900);
assert_eq!(scopes, ["spool:acme/api", "acme/tools"]);
assert_eq!(allowed_operations, ["Push", "GetState"]);
assert!(
Cli::try_parse_from([
"heddle",
"auth",
"derive-agent",
"--server",
"api.heddle.test",
"--stdout",
])
.is_err(),
"token-only child export is unsafe because it cannot carry its proof key"
);
}
#[test]
fn derive_agent_parses_the_runner_persona_and_rejects_wider_overrides() {
let cli = Cli::try_parse_from([
"heddle",
"auth",
"derive-agent",
"--server",
"api.heddle.test",
"--runner",
"--scope",
"spool:acme/api",
])
.expect("runner flags parse");
let Commands::Auth {
command: AuthCommands::DeriveAgent { runner, scopes, .. },
} = cli.command
else {
panic!("expected auth derive-agent");
};
assert!(runner);
assert_eq!(scopes, ["spool:acme/api"]);
for conflicting in [["--allow", "Push"], ["--template", "ci-landing"]] {
assert!(
Cli::try_parse_from([
"heddle",
"auth",
"derive-agent",
"--server",
"api.heddle.test",
"--runner",
conflicting[0],
conflicting[1],
])
.is_err(),
"runner persona must reject authority-changing overrides"
);
}
}
#[test]
fn grant_create_parses_spool_principal_and_writer_role() {
let cli = Cli::try_parse_from([
"heddle",
"grant",
"create",
"--spool",
"spool/willow-ibis-8e7264/notes",
"--principal",
"alice",
"--role",
"writer",
"--server",
"api.preview.heddle.sh",
])
.expect("grant create flags parse");
let Commands::Grant {
command: GrantCommands::Create(args),
} = cli.command
else {
panic!("expected grant create");
};
assert_eq!(args.spool, "spool/willow-ibis-8e7264/notes");
assert_eq!(args.principal, "alice");
assert_eq!(args.role, GrantRoleArg::Writer);
assert_eq!(args.role.as_resource_role_name(), "writer");
assert_eq!(args.server.server.as_deref(), Some("api.preview.heddle.sh"));
}
#[test]
fn grant_list_and_delete_parse() {
let list = Cli::try_parse_from([
"heddle",
"grant",
"list",
"--spool",
"notes",
"--server",
"api.preview.heddle.sh",
])
.expect("grant list flags parse");
let Commands::Grant {
command: GrantCommands::List(args),
} = list.command
else {
panic!("expected grant list");
};
assert_eq!(args.spool, "notes");
assert_eq!(args.server.server.as_deref(), Some("api.preview.heddle.sh"));
let delete = Cli::try_parse_from([
"heddle",
"grant",
"delete",
"alice",
"--spool",
"spool/willow-ibis-8e7264/notes",
])
.expect("grant delete flags parse");
let Commands::Grant {
command: GrantCommands::Delete(args),
} = delete.command
else {
panic!("expected grant delete");
};
assert_eq!(args.id, "alice");
assert_eq!(args.spool, "spool/willow-ibis-8e7264/notes");
assert!(Cli::try_parse_from(["heddle", "grant", "delete", "alice"]).is_err());
}
}