From 8fd900853c3443eff28dd62a7dbac3a54fde4975 Mon Sep 17 00:00:00 2001 From: Lee Reinhardt Date: Tue, 24 Mar 2026 14:42:01 -0600 Subject: [PATCH] feat: add interactive project/cohort selection and tags to claim-tokens create --- src/commands/connect/claim_tokens.rs | 138 ++++++++++++++++++++++++++- src/commands/connect/client.rs | 2 + src/main.rs | 20 +++- 3 files changed, 150 insertions(+), 10 deletions(-) diff --git a/src/commands/connect/claim_tokens.rs b/src/commands/connect/claim_tokens.rs index c93dab5..15c4d37 100644 --- a/src/commands/connect/claim_tokens.rs +++ b/src/commands/connect/claim_tokens.rs @@ -1,7 +1,7 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use crate::commands::connect::client::{ - self, ConnectClient, CreateClaimTokenParams, CreateClaimTokenRequest, + self, CohortInfo, ConnectClient, CreateClaimTokenParams, CreateClaimTokenRequest, ProjectInfo, }; use crate::utils::output::{print_info, print_success, OutputLevel}; @@ -50,8 +50,10 @@ impl ConnectClaimTokensListCommand { pub struct ConnectClaimTokensCreateCommand { pub org: String, + pub project: Option, + pub cohort: Option, pub name: String, - pub cohort_id: Option, + pub tags: Vec, pub max_uses: Option, pub no_expiration: bool, pub profile: Option, @@ -64,8 +66,10 @@ impl ConnectClaimTokensCreateCommand { let (_, profile) = config.resolve_profile(self.profile.as_deref(), Some(&self.org))?; let client = ConnectClient::from_profile(profile)?; + // Select project → cohort (interactive if flags not provided) + let selected_cohort = self.resolve_cohort(&client).await?; + let expires_at = if self.no_expiration { - // Far-future date to effectively disable expiration Some("2099-12-31T23:59:59Z".to_string()) } else { None @@ -74,9 +78,10 @@ impl ConnectClaimTokensCreateCommand { let req = CreateClaimTokenRequest { claim_token: CreateClaimTokenParams { name: self.name.clone(), - cohort_id: self.cohort_id.clone(), + cohort_id: selected_cohort.as_ref().map(|c| c.id.clone()), max_uses: self.max_uses, expires_at, + tags: self.tags.clone(), }, }; @@ -87,6 +92,17 @@ impl ConnectClaimTokensCreateCommand { OutputLevel::Normal, ); + if let Some(ref cohort) = selected_cohort { + if cohort.name.is_empty() { + println!(" Cohort: {}", cohort.id); + } else { + println!(" Cohort: {} ({})", cohort.name, cohort.id); + } + } + if !self.tags.is_empty() { + println!(" Tags: {}", self.tags.join(", ")); + } + // The raw token is only shown on creation if let Some(ref raw_token) = token.token { println!("\nToken value (save this — it cannot be retrieved later):"); @@ -95,6 +111,76 @@ impl ConnectClaimTokensCreateCommand { Ok(()) } + + async fn resolve_cohort(&self, client: &ConnectClient) -> Result> { + // If --cohort provided directly, use it as-is (API validates the ID) + if let Some(ref cohort_flag) = self.cohort { + return Ok(Some(CohortInfo { + id: cohort_flag.clone(), + name: String::new(), + })); + } + + // Interactive: select project then cohort + let projects = client.list_projects(&self.org).await?; + + if projects.is_empty() { + print_info( + "No projects found — claim token will be org-scoped.", + OutputLevel::Normal, + ); + return Ok(None); + } + + let selected_project = if let Some(ref proj_flag) = self.project { + projects + .iter() + .find(|p| p.id == *proj_flag) + .cloned() + .ok_or_else(|| { + anyhow::anyhow!( + "Project '{}' not found. Available: {}", + proj_flag, + projects + .iter() + .map(|p| p.name.as_str()) + .collect::>() + .join(", ") + ) + })? + } else if projects.len() == 1 { + let proj = projects[0].clone(); + print_info( + &format!("Auto-selected project: {} ({})", proj.name, proj.id), + OutputLevel::Normal, + ); + proj + } else { + prompt_select_project(&projects)? + }; + + let cohorts = client.list_cohorts(&self.org, &selected_project.id).await?; + + if cohorts.is_empty() { + anyhow::bail!( + "No cohorts found in project '{}'. Create a cohort first, or use --cohort to specify one directly.", + selected_project.name + ); + } + + let selected_cohort = if cohorts.len() == 1 { + let cohort = cohorts[0].clone(); + print_info( + &format!("Auto-selected cohort: {} ({})", cohort.name, cohort.id), + OutputLevel::Normal, + ); + cohort + } else { + prompt_select_cohort(&cohorts)? + }; + + Ok(Some(selected_cohort)) + } } pub struct ConnectClaimTokensDeleteCommand { @@ -134,3 +220,45 @@ impl ConnectClaimTokensDeleteCommand { Ok(()) } } + +fn prompt_select_project(projects: &[ProjectInfo]) -> Result { + println!("\nSelect a project:"); + for (i, proj) in projects.iter().enumerate() { + println!(" [{}] {} (id: {})", i + 1, proj.name, proj.id); + } + eprint!("\nEnter number (1-{}): ", projects.len()); + + let mut input = String::new(); + std::io::stdin() + .read_line(&mut input) + .context("Failed to read input")?; + + let choice: usize = input.trim().parse().context("Invalid number")?; + + if choice < 1 || choice > projects.len() { + anyhow::bail!("Selection out of range"); + } + + Ok(projects[choice - 1].clone()) +} + +fn prompt_select_cohort(cohorts: &[CohortInfo]) -> Result { + println!("\nSelect a cohort:"); + for (i, cohort) in cohorts.iter().enumerate() { + println!(" [{}] {} (id: {})", i + 1, cohort.name, cohort.id); + } + eprint!("\nEnter number (1-{}): ", cohorts.len()); + + let mut input = String::new(); + std::io::stdin() + .read_line(&mut input) + .context("Failed to read input")?; + + let choice: usize = input.trim().parse().context("Invalid number")?; + + if choice < 1 || choice > cohorts.len() { + anyhow::bail!("Selection out of range"); + } + + Ok(cohorts[choice - 1].clone()) +} diff --git a/src/commands/connect/client.rs b/src/commands/connect/client.rs index b747607..668b893 100644 --- a/src/commands/connect/client.rs +++ b/src/commands/connect/client.rs @@ -260,6 +260,8 @@ pub struct CreateClaimTokenParams { /// Set to a far-future date for no expiration, or omit for default (24h). #[serde(skip_serializing_if = "Option::is_none")] pub expires_at: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, } // --------------------------------------------------------------------------- diff --git a/src/main.rs b/src/main.rs index dd5609c..bd59c98 100644 --- a/src/main.rs +++ b/src/main.rs @@ -844,12 +844,18 @@ enum ConnectClaimTokensCommands { /// Organization ID (or set connect.org in avocado.yaml) #[arg(long)] org: Option, + /// Project ID (skip interactive prompt) + #[arg(long)] + project: Option, + /// Cohort ID (skip interactive prompt) + #[arg(long)] + cohort: Option, /// Token name #[arg(long)] name: String, - /// Cohort ID to assign claimed devices to - #[arg(long)] - cohort_id: Option, + /// Tags to associate with devices claimed using this token (repeatable) + #[arg(long, short = 't')] + tag: Vec, /// Maximum number of times this token can be used #[arg(long)] max_uses: Option, @@ -2752,8 +2758,10 @@ async fn main() -> Result<()> { } ConnectClaimTokensCommands::Create { org, + project, + cohort, name, - cohort_id, + tag, max_uses, no_expiration, config, @@ -2762,8 +2770,10 @@ async fn main() -> Result<()> { let resolved_org = commands::connect::resolve_org(org, &config)?; let cmd = ConnectClaimTokensCreateCommand { org: resolved_org, + project, + cohort, name, - cohort_id, + tags: tag, max_uses, no_expiration, profile,