use std::fmt;
use std::io::Write;
use std::time::Duration;
use anyhow::{Context, Result, anyhow, bail};
use clap::{Args, Subcommand};
use codewhale_config::ProviderKind;
use codewhale_config::route::parse_route_kind;
use serde::{Deserialize, Serialize};
use super::{
CloudClient, CloudRequest, CloudResponse, CloudTransport, HttpMethod, printable,
validate_api_base,
};
pub(crate) const MACHINE_KEY_ENV: &str = "CODEWHALE_API_KEY";
pub(crate) const MACHINE_API_BASE_ENV: &str = "CODEWHALE_API_BASE";
const TOKEN_PREFIX: &str = "cwc_key_";
const TOKEN_LEN: usize = 76;
const TOKEN_HEAD_LEN: usize = 32;
const KEY_ID_LEN: usize = 24;
const SCOPES: [&str; 3] = ["account:read", "agent:run", "models:infer"];
const MAX_LIVE_KEYS: usize = 25;
const MAX_KEY_NAME_CHARS: usize = 64;
const MAX_EXPIRY_DAYS: u32 = 365;
pub(crate) const EXIT_INPUT: i32 = 2;
pub(crate) const EXIT_AUTH: i32 = 3;
pub(crate) const EXIT_AGENT_UNCONFIGURED: i32 = 4;
pub(crate) const EXIT_LIMIT: i32 = 5;
pub(crate) const EXIT_UNAVAILABLE: i32 = 6;
pub(crate) const EXIT_TRANSPORT: i32 = 7;
const MAX_ATTEMPTS: u32 = 3;
const BASE_BACKOFF_MS: u64 = 500;
const MAX_BACKOFF_MS: u64 = 30_000;
#[derive(Clone)]
pub(crate) struct MachineKey(String);
impl MachineKey {
pub(crate) fn parse(raw: &str) -> Result<Self> {
let value = unwrap_quoted(raw);
if !token_is_well_formed(value) {
bail!(
"{MACHINE_KEY_ENV} is not a well-formed Codewhale API key, so it was not sent. \
Expected {TOKEN_LEN} characters shaped `cwc_key_<24 hex>_<43 chars>`; got {} characters. \
That is almost always a truncated or shell-mangled paste — re-copy the value, or create a \
new key with `codewhale account api-keys create`.",
value.chars().count()
);
}
Ok(Self(value.to_string()))
}
#[must_use]
pub(crate) fn head(&self) -> &str {
&self.0[..TOKEN_HEAD_LEN]
}
fn expose(&self) -> String {
self.0.clone()
}
}
impl fmt::Debug for MachineKey {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "MachineKey({}…)", self.head())
}
}
fn unwrap_quoted(raw: &str) -> &str {
let trimmed = raw.trim();
for quote in ['"', '\''] {
if trimmed.len() >= 2 && trimmed.starts_with(quote) && trimmed.ends_with(quote) {
return trimmed[1..trimmed.len() - 1].trim();
}
}
trimmed
}
fn token_is_well_formed(value: &str) -> bool {
let bytes = value.as_bytes();
if bytes.len() != TOKEN_LEN || !value.is_ascii() || !value.starts_with(TOKEN_PREFIX) {
return false;
}
let id_end = TOKEN_PREFIX.len() + KEY_ID_LEN;
if bytes[TOKEN_PREFIX.len()..id_end]
.iter()
.any(|byte| !byte.is_ascii_digit() && !(b'a'..=b'f').contains(byte))
{
return false;
}
if bytes[id_end] != b'_' {
return false;
}
bytes[id_end + 1..]
.iter()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
}
#[derive(Clone, Default)]
pub(crate) struct MachineKeyEnv {
raw: Option<String>,
}
impl MachineKeyEnv {
#[must_use]
pub(crate) fn from_process_env() -> Self {
Self::from_raw(std::env::var(MACHINE_KEY_ENV).ok().as_deref())
}
#[must_use]
pub(crate) fn from_raw(raw: Option<&str>) -> Self {
Self {
raw: raw
.map(str::to_string)
.filter(|value| !value.trim().is_empty()),
}
}
#[must_use]
pub(crate) fn is_present(&self) -> bool {
self.raw.is_some()
}
pub(crate) fn resolve(&self) -> Result<Option<MachineKey>> {
self.raw.as_deref().map(MachineKey::parse).transpose()
}
pub(crate) fn require(&self) -> Result<MachineKey> {
self.resolve()?.ok_or_else(|| {
anyhow!(
"This command authenticates with a Codewhale account API key. \
Set {MACHINE_KEY_ENV}, or create one with `codewhale account api-keys create` after \
`codewhale login`."
)
})
}
}
pub(crate) fn resolve_api_base(
flag: Option<&str>,
machine_base: Option<&str>,
device_base: Option<&str>,
default_base: &str,
) -> String {
[flag, machine_base, device_base]
.into_iter()
.flatten()
.map(str::trim)
.find(|value| !value.is_empty())
.map_or_else(
|| default_base.to_string(),
|value| value.trim_end_matches('/').to_string(),
)
}
pub(crate) fn require_secure_base(api_base: &str) -> Result<()> {
validate_api_base(api_base)
.map(|_| ())
.with_context(|| format!("refusing to send a {MACHINE_KEY_ENV} credential to {api_base}"))
}
#[derive(Debug, Default, Deserialize)]
struct ErrorEnvelope {
#[serde(default)]
message: String,
#[serde(default)]
details: ErrorDetails,
}
#[derive(Debug, Default, Deserialize)]
struct ErrorDetails {
#[serde(default)]
code: String,
#[serde(default)]
fields: Vec<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct MachineError {
pub(crate) status: u16,
pub(crate) code: String,
pub(crate) message: String,
pub(crate) exit_code: i32,
pub(crate) retryable: bool,
}
impl fmt::Display for MachineError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for MachineError {}
impl MachineError {
fn new(status: u16, code: &str, message: impl Into<String>, exit_code: i32) -> Self {
Self {
status,
code: code.to_string(),
message: message.into(),
exit_code,
retryable: false,
}
}
fn retryable(mut self) -> Self {
self.retryable = true;
self
}
pub(crate) fn transport(message: impl Into<String>) -> Self {
Self::new(0, "transport_error", message, EXIT_TRANSPORT).retryable()
}
}
pub(crate) fn classify(response: &CloudResponse) -> MachineError {
let envelope = serde_json::from_slice::<ErrorEnvelope>(&response.body).unwrap_or_default();
let code = sanitize_code(&envelope.details.code);
let server_message = sanitize_message(&envelope.message);
let status = response.status;
match code.as_str() {
"api_key_invalid" => MachineError::new(
status,
&code,
"That Codewhale API key is not valid. It may have been mistyped, truncated, or \
deleted. Create a new one with `codewhale account api-keys create`.",
EXIT_AUTH,
),
"api_key_revoked" => MachineError::new(
status,
&code,
format!(
"This Codewhale API key was revoked. Create a new one and update {MACHINE_KEY_ENV}."
),
EXIT_AUTH,
),
"api_key_expired" => MachineError::new(
status,
&code,
format!(
"This Codewhale API key expired. Create a new one and update {MACHINE_KEY_ENV}."
),
EXIT_AUTH,
),
"api_key_required" => MachineError::new(
status,
&code,
"This Codewhale route accepts only an account API key, but the CLI sent an \
interactive session token. That is a Codewhale CLI bug, not a problem with your \
credentials — please report it.",
EXIT_AUTH,
),
"auth_required" => MachineError::new(
status,
&code,
format!(
"No Codewhale credential reached the server. Set {MACHINE_KEY_ENV}, or run \
`codewhale login`."
),
EXIT_AUTH,
),
"api_key_route_denied" => MachineError::new(
status,
&code,
"A Codewhale API key cannot be used for this command. Managing API keys needs an \
interactive login. Run `codewhale login`.",
EXIT_AUTH,
),
"api_key_scope_denied" => MachineError::new(
status,
&code,
join_message(
&server_message,
"This key does not have the scope this command needs. Create a new key with it \
using `codewhale account api-keys create --scope <scope>`.",
),
EXIT_AUTH,
),
"account_agent_model_unconfigured" => MachineError::new(
status,
&code,
"This Codewhale account has no agent model configured. Choose one in the Codewhale \
app, or run `codewhale account keys set <provider>`.",
EXIT_AGENT_UNCONFIGURED,
),
"api_key_limit_reached" => MachineError::new(
status,
&code,
format!(
"This account already has {MAX_LIVE_KEYS} active API keys. Revoke one first with \
`codewhale account api-keys revoke <id>`."
),
EXIT_LIMIT,
),
"api_key_field_unknown" => {
let fields = envelope
.details
.fields
.iter()
.filter_map(|field| {
let field = sanitize_code(field);
(!field.is_empty()).then_some(field)
})
.collect::<Vec<_>>();
let detail = if fields.is_empty() {
String::new()
} else {
format!("Unknown field(s): {}.", fields.join(", "))
};
MachineError::new(
status,
&code,
join_message(&server_message, &detail),
EXIT_INPUT,
)
}
"api_key_name_invalid"
| "api_key_expiry_invalid"
| "api_key_scopes_invalid"
| "api_key_body_invalid" => MachineError::new(
status,
&code,
join_message(&server_message, "Adjust the command and run it again."),
EXIT_INPUT,
),
"api_key_not_found" => MachineError::new(
status,
&code,
"No such Codewhale API key. Run `codewhale account api-keys list` to see the ids \
this account owns.",
EXIT_INPUT,
),
"api_key_unavailable" => MachineError::new(
status,
&code,
"This Codewhale deployment does not support API keys yet. Retrying will not help; \
the deployment needs a control-plane upgrade.",
EXIT_UNAVAILABLE,
),
"control_plane_not_attached" => MachineError::new(
status,
&code,
"This Codewhale edge deployment has not been attached to the account control plane, \
so the API key routes are not reachable from it. This is a routing/deployment fix, not a \
missing feature and not a bad key.",
EXIT_UNAVAILABLE,
),
_ => classify_by_status(status, &code, &server_message),
}
}
fn classify_by_status(status: u16, code: &str, server_message: &str) -> MachineError {
let code = if code.is_empty() { "unknown" } else { code };
let context = if server_message.is_empty() {
format!("The Codewhale service returned HTTP {status}.")
} else {
format!("The Codewhale service returned HTTP {status}: {server_message}")
};
match status {
401 | 403 => MachineError::new(status, code, context, EXIT_AUTH),
429 => MachineError::new(
status,
code,
format!("{context} The request was rate limited."),
EXIT_TRANSPORT,
)
.retryable(),
400 | 404 | 409 | 422 => MachineError::new(status, code, context, EXIT_INPUT),
500..=504 => MachineError::new(status, code, context, EXIT_TRANSPORT).retryable(),
_ => MachineError::new(status, code, context, EXIT_TRANSPORT),
}
}
fn join_message(primary: &str, fallback: &str) -> String {
match (primary.is_empty(), fallback.is_empty()) {
(true, _) => fallback.to_string(),
(false, true) => primary.to_string(),
(false, false) => format!("{primary} {fallback}"),
}
}
fn sanitize_code(code: &str) -> String {
let code = code.trim();
if code.is_empty()
|| code.len() > 80
|| !code
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
{
return String::new();
}
code.to_string()
}
fn sanitize_message(message: &str) -> String {
printable(message)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Retry {
Idempotent,
Never,
}
pub(crate) fn backoff_delay(attempt: u32, retry_after_seconds: Option<u64>) -> Duration {
if let Some(seconds) = retry_after_seconds {
return Duration::from_millis((seconds.saturating_mul(1000)).min(MAX_BACKOFF_MS));
}
let exponential = BASE_BACKOFF_MS.saturating_mul(1u64 << attempt.min(6).saturating_sub(1));
let jitter = u64::from(attempt).saturating_mul(137) % 250;
Duration::from_millis(exponential.saturating_add(jitter).min(MAX_BACKOFF_MS))
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MachineAccount {
#[serde(default)]
pub(crate) id: String,
#[serde(default)]
pub(crate) display_name: String,
#[serde(default)]
pub(crate) email: String,
#[serde(default)]
pub(crate) region: String,
#[serde(default)]
pub(crate) plan: String,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ApiKeyMetadata {
#[serde(default)]
pub(crate) id: String,
#[serde(default)]
pub(crate) name: String,
#[serde(default)]
pub(crate) display_prefix: String,
#[serde(default)]
pub(crate) scopes: Vec<String>,
#[serde(default)]
pub(crate) created_at: String,
#[serde(default)]
pub(crate) expires_at: Option<String>,
#[serde(default)]
pub(crate) last_used_at: Option<String>,
#[serde(default)]
pub(crate) revoked_at: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AgentState {
#[serde(default)]
pub(crate) configured: bool,
#[serde(default)]
pub(crate) model_provider: String,
#[serde(default)]
pub(crate) account_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct WhoamiResponse {
#[serde(default)]
pub(crate) account: MachineAccount,
#[serde(default)]
pub(crate) api_key: ApiKeyMetadata,
#[serde(default)]
pub(crate) agent: AgentState,
}
#[derive(Debug, Deserialize)]
pub(crate) struct AgentResponse {
#[serde(default)]
pub(crate) agent: AgentState,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ApiKeyListResponse {
#[serde(default)]
api_keys: Vec<ApiKeyMetadata>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ApiKeyCreateResponse {
#[serde(default)]
api_key: ApiKeyMetadata,
#[serde(default)]
secret: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ApiKeyRevokeResponse {
#[serde(default)]
api_key: ApiKeyMetadata,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ApiKeyCreateRequest<'a> {
name: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
expires_in_days: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
scopes: Option<Vec<String>>,
}
pub(crate) struct MachineClient<'a, T: CloudTransport> {
transport: &'a T,
key: MachineKey,
}
impl<'a, T: CloudTransport> MachineClient<'a, T> {
pub(crate) fn new(transport: &'a T, key: MachineKey) -> Self {
Self { transport, key }
}
#[must_use]
pub(crate) fn key_head(&self) -> &str {
self.key.head()
}
pub(crate) fn whoami(&self, sleeper: &mut dyn FnMut(Duration)) -> Result<WhoamiResponse> {
self.get_json("/api/account/api-key/whoami", sleeper)
}
pub(crate) fn agent(&self, sleeper: &mut dyn FnMut(Duration)) -> Result<AgentResponse> {
self.get_json("/api/account/api-key/agent", sleeper)
}
fn get_json<R: serde::de::DeserializeOwned>(
&self,
path: &str,
sleeper: &mut dyn FnMut(Duration),
) -> Result<R> {
let response = send_with_retry(self.transport, Retry::Idempotent, sleeper, || {
CloudRequest {
method: HttpMethod::Get,
path: path.to_string(),
bearer: Some(self.key.expose()),
body: None,
}
})?;
decode_json(response)
}
}
fn send_with_retry<T: CloudTransport>(
transport: &T,
retry: Retry,
sleeper: &mut dyn FnMut(Duration),
build: impl Fn() -> CloudRequest,
) -> Result<CloudResponse> {
let max_attempts = if retry == Retry::Idempotent {
MAX_ATTEMPTS
} else {
1
};
let mut attempt = 1;
loop {
let (error, retry_after) = match transport.execute(build()) {
Ok(response) if (200..300).contains(&response.status) => return Ok(response),
Ok(response) => {
let retry_after = response.retry_after;
(classify(&response), retry_after)
}
Err(err) => (
MachineError::transport(format!(
"Could not reach the Codewhale service: {}",
printable(&err.to_string())
)),
None,
),
};
if !error.retryable || attempt >= max_attempts {
return Err(anyhow::Error::new(error));
}
sleeper(backoff_delay(attempt, retry_after));
attempt += 1;
}
}
fn decode_json<R: serde::de::DeserializeOwned>(response: CloudResponse) -> Result<R> {
serde_json::from_slice(&response.body)
.context("The Codewhale service returned an invalid JSON response")
}
#[derive(Debug, Args)]
pub(crate) struct ApiKeysArgs {
#[command(subcommand)]
command: ApiKeysCommand,
}
#[derive(Debug, Subcommand)]
enum ApiKeysCommand {
Create(ApiKeyCreateArgs),
List,
Revoke {
id: String,
},
}
#[derive(Debug, Args)]
pub(crate) struct ApiKeyCreateArgs {
#[arg(long)]
name: String,
#[arg(long = "expires-in-days", value_parser = clap::value_parser!(u32).range(1..=i64::from(MAX_EXPIRY_DAYS)))]
expires_in_days: Option<u32>,
#[arg(long = "scope", value_name = "SCOPE")]
scopes: Vec<String>,
#[arg(long = "use", default_value_t = false)]
use_locally: bool,
}
pub(crate) fn validate_key_name(name: &str) -> Result<&str> {
let invalid = || {
anyhow!(
"API key name must be 1-{MAX_KEY_NAME_CHARS} characters, start with a letter or \
digit, and contain only letters, digits, spaces, and `. _ : @ / -`."
)
};
let mut characters = name.chars();
let Some(first) = characters.next() else {
return Err(invalid());
};
if !first.is_ascii_alphanumeric() || name.chars().count() > MAX_KEY_NAME_CHARS {
return Err(invalid());
}
if characters.any(|character| {
!character.is_ascii_alphanumeric()
&& !matches!(character, ' ' | '.' | '_' | ':' | '@' | '/' | '-')
}) {
return Err(invalid());
}
Ok(name)
}
pub(crate) fn validate_scopes(scopes: &[String]) -> Result<Option<Vec<String>>> {
if scopes.is_empty() {
return Ok(Some(
SCOPES.iter().map(|scope| (*scope).to_string()).collect(),
));
}
let mut normalized = Vec::new();
for scope in scopes {
let scope = scope.trim();
if !SCOPES.contains(&scope) {
bail!(
"unknown scope `{}`; Codewhale API keys accept only {}",
printable(scope),
SCOPES.join(", ")
);
}
if !normalized.iter().any(|existing| existing == scope) {
normalized.push(scope.to_string());
}
}
Ok(Some(normalized))
}
pub(crate) fn validate_key_id(id: &str) -> Result<&str> {
let id = id.trim();
if id.len() != KEY_ID_LEN
|| !id
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
bail!(
"API key id must be {KEY_ID_LEN} lowercase hex characters — the part after `cwc_key_` \
in the display prefix. Run `codewhale account api-keys list` to see them."
);
}
Ok(id)
}
pub(crate) fn reject_machine_key_for_management(
machine: &MachineKeyEnv,
has_session: bool,
) -> Result<()> {
if machine.is_present() && !has_session {
bail!(
"Managing API keys needs an interactive login. Run `codewhale login`.\n\
{MACHINE_KEY_ENV} is set, but a Codewhale API key deliberately cannot create, list, or \
revoke keys — that is what stops a leaked key from minting a replacement for itself."
);
}
Ok(())
}
pub(crate) fn run_api_keys<T: CloudTransport, W: Write>(
args: ApiKeysArgs,
client: &CloudClient<'_, T>,
machine: &MachineKeyEnv,
provider_secrets: &codewhale_secrets::Secrets,
out: &mut W,
sleeper: &mut dyn FnMut(Duration),
) -> Result<()> {
reject_machine_key_for_management(machine, client.has_session()?)?;
match args.command {
ApiKeysCommand::Create(create) => {
let name = validate_key_name(create.name.trim())?;
let scopes = validate_scopes(&create.scopes)?;
let body = serde_json::to_vec(&ApiKeyCreateRequest {
name,
expires_in_days: create.expires_in_days,
scopes,
})
.context("failed to encode the Codewhale API key request")?;
let response = client.execute_authenticated_with_retry(
HttpMethod::Post,
"/api/account/api-keys",
Some(body),
Retry::Never,
sleeper,
)?;
if !(200..300).contains(&response.status) {
return Err(anyhow::Error::new(classify(&response)));
}
let created: ApiKeyCreateResponse = decode_json(response)?;
write_created_key(out, &created)?;
if create.use_locally {
save_key_as_local_codewhale_credential(provider_secrets, &created.secret, out)?;
}
Ok(())
}
ApiKeysCommand::List => {
let response = client.execute_authenticated_with_retry(
HttpMethod::Get,
"/api/account/api-keys",
None,
Retry::Idempotent,
sleeper,
)?;
if !(200..300).contains(&response.status) {
return Err(anyhow::Error::new(classify(&response)));
}
let listing: ApiKeyListResponse = decode_json(response)?;
write_key_listing(out, &listing.api_keys)
}
ApiKeysCommand::Revoke { id } => {
let id = validate_key_id(&id)?;
let path = format!("/api/account/api-keys/{id}");
let response = client.execute_authenticated_with_retry(
HttpMethod::Delete,
&path,
None,
Retry::Idempotent,
sleeper,
)?;
if !(200..300).contains(&response.status) {
return Err(anyhow::Error::new(classify(&response)));
}
let revoked: ApiKeyRevokeResponse = decode_json(response)?;
writeln!(
out,
"Revoked Codewhale API key {}.",
printable(&revoked.api_key.id)
)?;
if let Some(revoked_at) = revoked.api_key.revoked_at.as_deref() {
writeln!(out, "Revoked at: {}", printable(revoked_at))?;
}
writeln!(
out,
"Revocation takes effect on the key's next request. Repeating this command is safe."
)?;
Ok(())
}
}
}
fn write_created_key<W: Write>(out: &mut W, created: &ApiKeyCreateResponse) -> Result<()> {
let metadata = &created.api_key;
writeln!(
out,
"Created Codewhale API key {}.",
printable(&metadata.id)
)?;
writeln!(out, "Name: {}", printable(&metadata.name))?;
writeln!(out, "Scopes: {}", printable(&metadata.scopes.join(", ")))?;
writeln!(
out,
"Expires: {}",
metadata
.expires_at
.as_deref()
.map_or_else(|| "never".to_string(), printable)
)?;
writeln!(out)?;
writeln!(
out,
"-- THIS IS THE ONLY TIME YOU WILL SEE THIS SECRET ------------------"
)?;
writeln!(out, "{}", created.secret)?;
writeln!(
out,
"-------------------------------------------------------------------"
)?;
writeln!(
out,
"Codewhale stores only a hash of it and cannot show it again. Copy it now into \
{MACHINE_KEY_ENV}. If you lose it, revoke this key and create another."
)?;
Ok(())
}
fn save_key_as_local_codewhale_credential<W: Write>(
secrets: &codewhale_secrets::Secrets,
secret: &str,
out: &mut W,
) -> Result<()> {
let key = MachineKey::parse(secret)?;
let slot = ProviderKind::Codewhale.secret_store_slot();
secrets.set(slot, secret).map_err(|error| {
anyhow!(
"The key was created, but saving it to the local secret store ({slot}) failed: {error}. Copy the secret above into {MACHINE_KEY_ENV} instead."
)
})?;
writeln!(out)?;
writeln!(
out,
"Saved {} as this machine's local `codewhale` provider credential.",
key.head()
)?;
writeln!(
out,
"Select it with `codewhale config set provider codewhale`; models come from the account's own connected providers."
)?;
Ok(())
}
fn write_key_listing<W: Write>(out: &mut W, keys: &[ApiKeyMetadata]) -> Result<()> {
if keys.is_empty() {
writeln!(out, "This Codewhale account has no API keys.")?;
writeln!(
out,
"Create one with `codewhale account api-keys create --name <name>`."
)?;
return Ok(());
}
for key in keys {
let state = if key.revoked_at.is_some() {
"revoked"
} else {
"active"
};
writeln!(
out,
"{} {} [{state}] scopes={}",
printable(&key.id),
printable(&key.name),
printable(&key.scopes.join(","))
)?;
writeln!(out, " prefix: {}", printable(&key.display_prefix))?;
writeln!(out, " created: {}", printable(&key.created_at))?;
writeln!(
out,
" expires: {}",
key.expires_at
.as_deref()
.map_or_else(|| "never".to_string(), printable)
)?;
writeln!(
out,
" last used: {}",
key.last_used_at
.as_deref()
.map_or_else(|| "never".to_string(), printable)
)?;
if let Some(revoked_at) = key.revoked_at.as_deref() {
writeln!(out, " revoked: {}", printable(revoked_at))?;
}
}
Ok(())
}
pub(crate) fn write_whoami<W: Write>(
out: &mut W,
who: &WhoamiResponse,
api_base: &str,
key_head: &str,
) -> Result<()> {
writeln!(out, "Authenticated to Codewhale with an account API key.")?;
writeln!(out, "Account ID: {}", printable(&who.account.id))?;
if !who.account.display_name.trim().is_empty() {
writeln!(out, "Name: {}", printable(&who.account.display_name))?;
}
if !who.account.email.trim().is_empty() {
writeln!(out, "Email: {}", printable(&who.account.email))?;
}
if !who.account.region.trim().is_empty() {
writeln!(out, "Region: {}", printable(&who.account.region))?;
}
if !who.account.plan.trim().is_empty() {
writeln!(out, "Plan: {}", printable(&who.account.plan))?;
}
writeln!(out, "API: {api_base}")?;
writeln!(out, "Key: {key_head} ({})", printable(&who.api_key.name))?;
writeln!(
out,
"Key scopes: {}",
printable(&who.api_key.scopes.join(", "))
)?;
if let Some(expires_at) = who.api_key.expires_at.as_deref() {
writeln!(out, "Key expires: {}", printable(expires_at))?;
}
if who.agent.configured {
writeln!(out, "Agent model: {}", printable(&who.agent.model_provider))?;
} else {
writeln!(
out,
"Agent model: not configured — this key authenticates, but agent work will refuse \
until a model is chosen in the Codewhale app or with `codewhale account keys set <provider>`."
)?;
}
Ok(())
}
pub(crate) fn write_agent<W: Write>(out: &mut W, agent: &AgentState) -> Result<()> {
writeln!(
out,
"Codewhale agent model: {}",
printable(&agent.model_provider)
)?;
if let Some(account_id) = agent.account_id.as_deref() {
writeln!(out, "Account ID: {}", printable(account_id))?;
}
writeln!(out, "This account is ready to run machine work.")?;
Ok(())
}
pub(crate) fn review_provider_from_agent(agent: &AgentState) -> Result<ProviderKind> {
if !agent.configured {
return Err(anyhow::Error::new(MachineError::new(
409,
"account_agent_model_unconfigured",
"This Codewhale account has no agent model configured. Choose one in the Codewhale \
app, or run `codewhale account keys set <provider>`.",
EXIT_AGENT_UNCONFIGURED,
)));
}
let provider = agent.model_provider.trim();
parse_route_kind(provider).ok_or_else(|| {
anyhow!(
"This Codewhale account is configured for agent provider `{}`, which this CLI build \
does not know. Upgrade `codewhale`, or choose a supported provider in the Codewhale app.",
printable(provider)
)
})
}
#[cfg(test)]
mod tests;