ref:64a668ed4786bacd4da37ddee1b8b93c88dd3d5a

feat(cli): add SSH key management commands

SHA: 64a668ed4786bacd4da37ddee1b8b93c88dd3d5a
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-03-15 22:17
Parents: 74144b3
2 files changed +107 -0
Type
src/commands/mod.rs +5 −0
@@ -10,5 +10,6 @@
pub mod release;
pub mod repo;
pub mod runner;
pub mod ssh_key;
use clap::{Parser, Subcommand};
@@ -50,6 +51,9 @@
Runner(runner::RunnerArgs),
/// Label operations
Label(label::LabelArgs),
/// SSH key management
#[command(name = "ssh-key")]
SshKey(ssh_key::SshKeyArgs),
}
pub async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
@@ -68,5 +72,6 @@
.await
.map_err(|e| -> Box<dyn std::error::Error> { e }),
Command::Label(args) => label::run(args).await,
Command::SshKey(args) => ssh_key::run(args).await,
}
}
src/commands/ssh_key.rs +102 −0
@@ -1,0 +1,102 @@
use crate::client::Client;
use crate::output;
use clap::{Args, Subcommand};
use serde::Deserialize;
#[derive(Args)]
pub struct SshKeyArgs {
#[command(subcommand)]
pub command: SshKeyCommand,
}
#[derive(Subcommand)]
pub enum SshKeyCommand {
/// List your SSH keys
List,
/// Add an SSH key
Add {
/// Key name/label
#[arg(long)]
name: String,
/// Path to public key file (e.g., ~/.ssh/id_ed25519.pub)
#[arg(long)]
key_file: String,
},
/// Remove an SSH key
Remove {
/// Key ID
id: String,
},
}
#[derive(Debug, Deserialize)]
struct SshKey {
id: Option<String>,
name: Option<String>,
fingerprint: Option<String>,
last_used_at: Option<String>,
inserted_at: Option<String>,
}
pub async fn run(args: SshKeyArgs) -> Result<(), Box<dyn std::error::Error>> {
match args.command {
SshKeyCommand::List => list().await,
SshKeyCommand::Add { name, key_file } => add(&name, &key_file).await,
SshKeyCommand::Remove { id } => remove(&id).await,
}
}
async fn list() -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let resp: serde_json::Value = client.get("/user/ssh-keys").await?;
let keys: Vec<SshKey> = resp
.get("ssh_keys")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
output::header("SSH Keys");
let rows: Vec<Vec<String>> = keys
.iter()
.map(|k| {
vec![
k.name.clone().unwrap_or_default(),
k.fingerprint.clone().unwrap_or_default(),
k.last_used_at
.as_deref()
.map(|t| output::format_time(t))
.unwrap_or("never".to_string()),
k.inserted_at
.as_deref()
.map(|t| output::format_time(t))
.unwrap_or_default(),
]
})
.collect();
output::print_table(&["NAME", "FINGERPRINT", "LAST USED", "ADDED"], &rows);
Ok(())
}
async fn add(name: &str, key_file: &str) -> Result<(), Box<dyn std::error::Error>> {
let public_key = std::fs::read_to_string(key_file)
.map_err(|e| format!("Failed to read key file '{}': {}", key_file, e))?;
let client = Client::from_config()?;
let resp: serde_json::Value = client
.post(
"/user/ssh-keys",
&serde_json::json!({"name": name, "public_key": public_key.trim()}),
)
.await?;
let fingerprint = resp
.get("fingerprint")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
output::success(&format!("Added SSH key '{name}'"));
output::detail("Fingerprint", fingerprint);
Ok(())
}
async fn remove(id: &str) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
client.delete_empty(&format!("/user/ssh-keys/{id}")).await?;
output::success(&format!("Removed SSH key {id}"));
Ok(())
}