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 /// Secret key 382 #[arg(long)] 383 pub key: String, 384 - /// Secret value 385 #[arg(long)] 386 pub value: String, 387 }
··· 381 /// Secret key 382 #[arg(long)] 383 pub key: String, 384 + /// Secret value (use '@filename' to read from file, '-' to read from stdin) 385 #[arg(long)] 386 pub value: String, 387 }
+26 -1
crates/tangled-cli/src/commands/spindle.rs
··· 250 .unwrap_or_else(|| "https://spindle.tangled.sh".to_string()); 251 let api = tangled_api::TangledClient::new(&spindle_base); 252 253 - api.add_repo_secret(&pds, &session.access_jwt, &repo_at, &args.key, &args.value) 254 .await?; 255 println!("Added secret '{}' to {}", args.key, args.repo); 256 Ok(())
··· 250 .unwrap_or_else(|| "https://spindle.tangled.sh".to_string()); 251 let api = tangled_api::TangledClient::new(&spindle_base); 252 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) 279 .await?; 280 println!("Added secret '{}' to {}", args.key, args.repo); 281 Ok(())