Rust CLI for tangled

Add support for multiline secrets via stdin and file input

Support reading secret values from stdin or files to handle
multiline content like SSH keys, certificates, and config files.

New value patterns:
- `--value -` reads from stdin
- `--value @<path>` reads from file
- `--value <text>` uses literal value (backward compatible)

File path handling:
- Supports tilde expansion for home directory (~/)
- Provides clear error messages if file cannot be read

Examples:
# From stdin
cat ~/.ssh/id_ed25519 | tangled spindle secret add \
--repo myrepo --key SSH_KEY --value -

# From file
tangled spindle secret add --repo myrepo \
--key SSH_KEY --value @~/.ssh/id_ed25519

# Literal value (existing behavior)
tangled spindle secret add --repo myrepo \
--key API_KEY --value "my-secret-key"

Fixes issue where multiline values were split into multiple
arguments by the shell, causing clap parsing errors.

Changed files
+27 -2
crates
tangled-cli
src
commands
+1 -1
crates/tangled-cli/src/cli.rs
··· 381 381 /// Secret key 382 382 #[arg(long)] 383 383 pub key: String, 384 - /// Secret value 384 + /// Secret value (use '@filename' to read from file, '-' to read from stdin) 385 385 #[arg(long)] 386 386 pub value: String, 387 387 }
+26 -1
crates/tangled-cli/src/commands/spindle.rs
··· 250 250 .unwrap_or_else(|| "https://spindle.tangled.sh".to_string()); 251 251 let api = tangled_api::TangledClient::new(&spindle_base); 252 252 253 - api.add_repo_secret(&pds, &session.access_jwt, &repo_at, &args.key, &args.value) 253 + // Handle special value patterns: @file or - (stdin) 254 + let value = if args.value == "-" { 255 + // Read from stdin 256 + use std::io::Read; 257 + let mut buffer = String::new(); 258 + std::io::stdin().read_to_string(&mut buffer)?; 259 + buffer 260 + } else if let Some(path) = args.value.strip_prefix('@') { 261 + // Read from file, expand ~ if needed 262 + let expanded_path = if path.starts_with("~/") { 263 + if let Some(home) = std::env::var("HOME").ok() { 264 + path.replacen("~/", &format!("{}/", home), 1) 265 + } else { 266 + path.to_string() 267 + } 268 + } else { 269 + path.to_string() 270 + }; 271 + std::fs::read_to_string(&expanded_path) 272 + .map_err(|e| anyhow!("Failed to read file '{}': {}", expanded_path, e))? 273 + } else { 274 + // Use value as-is 275 + args.value 276 + }; 277 + 278 + api.add_repo_secret(&pds, &session.access_jwt, &repo_at, &args.key, &value) 254 279 .await?; 255 280 println!("Added secret '{}' to {}", args.key, args.repo); 256 281 Ok(())