Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ config = "0.14"
# Database
rusqlite = { version = "0.32", features = ["bundled"] }

# Cryptography (API key hashing)
sha2 = "0.10"
hex = "0.4"

# Internal crates
postghost = { path = "crates/postghost" }
postghost-server = { path = "crates/postghost-server" }
Expand Down
24 changes: 24 additions & 0 deletions crates/postghost-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,30 @@ pub enum Commands {
state: Option<String>,
},

/// Manage agent API keys
Key {
#[command(subcommand)]
command: KeyCommands,
},

/// Show server health and Iris connection status
Doctor,
}

#[derive(Subcommand)]
pub enum KeyCommands {
/// Create a new agent API key
Create {
/// Human-readable name for the key
#[arg(long)]
name: String,
},
/// List all agent API keys (without raw values)
List,
/// Revoke an agent API key by id
Revoke {
/// Key id
#[arg(long)]
id: String,
},
}
75 changes: 75 additions & 0 deletions crates/postghost-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,81 @@ async fn main() -> Result<()> {
}
}
}

Commands::Key { command } => match command {
cli::KeyCommands::Create { name } => {
let resp = client
.post(format!("{}/api/v1/keys", base))
.json(&json!({ "name": name }))
.send()
.await?;
if resp.status().is_success() {
let body: serde_json::Value = resp.json().await?;
// Surface the raw key prominently — it's only returned once.
if let Some(key) = body.get("key").and_then(|v| v.as_str()) {
println!("API key created. Store this securely — it won't be shown again:");
println!(" {}", key);
println!();
println!(
"id: {}",
body.get("id").and_then(|v| v.as_str()).unwrap_or("?")
);
println!(
"name: {}",
body.get("name").and_then(|v| v.as_str()).unwrap_or("?")
);
println!(
"created_at: {}",
body.get("created_at")
.and_then(|v| v.as_str())
.unwrap_or("?")
);
} else {
println!("{}", serde_json::to_string_pretty(&body)?);
}
} else {
eprintln!("Error: HTTP {} — {}", resp.status(), resp.text().await?);
}
}
cli::KeyCommands::List => {
let resp = client.get(format!("{}/api/v1/keys", base)).send().await?;
if resp.status().is_success() {
let body: serde_json::Value = resp.json().await?;
if let Some(keys) = body.get("keys").and_then(|k| k.as_array()) {
if keys.is_empty() {
println!("No agent keys.");
} else {
for k in keys {
println!(
"{} {} created={} last_used={}",
k.get("id").and_then(|v| v.as_str()).unwrap_or("?"),
k.get("name").and_then(|v| v.as_str()).unwrap_or("?"),
k.get("created_at").and_then(|v| v.as_str()).unwrap_or("?"),
k.get("last_used_at")
.and_then(|v| v.as_str())
.unwrap_or("never"),
);
}
}
}
} else {
eprintln!("Error: HTTP {} — {}", resp.status(), resp.text().await?);
}
}
cli::KeyCommands::Revoke { id } => {
let resp = client
.delete(format!("{}/api/v1/keys/{}", base, id))
.send()
.await?;
if resp.status() == reqwest::StatusCode::NO_CONTENT {
println!("Key {} revoked.", id);
} else if resp.status() == reqwest::StatusCode::NOT_FOUND {
eprintln!("Key {} not found.", id);
} else {
eprintln!("Error: HTTP {} — {}", resp.status(), resp.text().await?);
}
}
},
}

Ok(())
Expand Down
6 changes: 6 additions & 0 deletions crates/postghost-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,9 @@ config.workspace = true
chrono.workspace = true
uuid.workspace = true
rusqlite.workspace = true
sha2.workspace = true
hex.workspace = true

[dev-dependencies]
tower = { workspace = true, features = ["util"] }
http-body-util = "0.1"
Loading
Loading