1use std::fmt::Write as _;
9use std::io::Write as _;
10
11use clap::{Args, Subcommand};
12
13use crate::api::{Client, ClientConfig};
14use crate::cli::{Session, emit, guidance, report, wizard};
15use crate::config::{OrgKind, Profile, store};
16use crate::exit::ExitCode;
17use crate::render::style::{Painter, Palette};
18use crate::secrets;
19
20#[derive(Debug, Subcommand)]
21pub enum AuthCommand {
22 #[command(long_about = crate::cli::guidance::login_help())]
24 Login(LoginArgs),
25 #[command(long_about = crate::cli::help::md(crate::cli::help::AUTH_LOGOUT))]
27 Logout {
28 #[arg(long, short = 'a')]
29 account: String,
30 },
31 #[command(long_about = crate::cli::help::md(crate::cli::help::AUTH_LIST))]
33 List,
34 #[command(long_about = crate::cli::help::md(crate::cli::help::AUTH_USE))]
36 Use {
37 profile: String,
39 },
40 #[command(long_about = crate::cli::help::md(crate::cli::help::AUTH_STATUS))]
42 Status {
43 #[arg(long)]
45 brief: bool,
46 #[arg(long)]
48 active_only: bool,
49 },
50}
51
52#[derive(Debug, Args)]
58pub struct LoginArgs {
59 #[arg(long, short = 'a')]
61 pub account: Option<String>,
62
63 #[arg(long)]
65 pub org_id: Option<String>,
66
67 #[arg(long, value_enum)]
69 pub org_kind: Option<OrgKind>,
70
71 #[arg(long, short = 'p')]
73 pub profile: Option<String>,
74
75 #[arg(long, short = 'q')]
77 pub queue: Option<String>,
78
79 #[arg(long)]
81 pub default: bool,
82
83 #[arg(long)]
85 pub no_verify: bool,
86}
87
88pub async fn run(command: &AuthCommand, session: &Session) -> ExitCode {
90 match command {
91 AuthCommand::Status { brief, active_only } => status(session, *brief, *active_only).await,
92 AuthCommand::Login(args) => login(args, session).await,
93 AuthCommand::Logout { account } => logout(account),
94 AuthCommand::List => list(session),
95 AuthCommand::Use { profile } => use_profile(session, profile),
96 }
97}
98
99async fn status(session: &Session, brief: bool, active_only: bool) -> ExitCode {
110 let mut out = anstream::stdout();
111 let mut err = anstream::stderr();
112 let paint = session.render.painter();
113
114 if session.config.profiles.is_empty() {
115 let _ = writeln!(err, "no profiles configured yet.\n");
116 let _ = writeln!(err, "{}", guidance::full());
117 let _ = writeln!(
118 err,
119 "Then: ytcli auth login --account <name> --org-id <id> [--queue <QUEUE>]"
120 );
121 return ExitCode::Auth;
122 }
123
124 report_sources(session, paint, &mut out);
125
126 let active = session
127 .resolved
128 .as_ref()
129 .map(|resolved| resolved.name.clone());
130 let mut active_failure = None;
131 let mut any_success = false;
132 let mut last_failure = None;
133 let mut queues_seen: std::collections::BTreeMap<String, Vec<String>> =
135 std::collections::BTreeMap::new();
136
137 for (name, profile) in &session.config.profiles {
138 let is_active = active.as_deref() == Some(name.as_str());
139 if active_only && !is_active {
140 continue;
141 }
142
143 let source = if is_active {
144 session
145 .resolved
146 .as_ref()
147 .map_or_else(String::new, |resolved| {
148 format!(" (from {})", resolved.source)
149 })
150 } else {
151 String::new()
152 };
153 let marks = if is_active { " [active]" } else { "" };
154
155 let _ = writeln!(
156 out,
157 "{} {}{}{}",
158 paint.paint("profile", Palette::label()),
159 paint.paint(name, Palette::key()),
160 paint.paint(&source, Palette::label()),
161 paint.paint(marks, Palette::ok()),
162 );
163 let _ = writeln!(
164 out,
165 " {} {} {} {} ({:?}) {} {}",
166 paint.paint("account:", Palette::label()),
167 profile.account,
168 paint.paint("org:", Palette::label()),
169 profile.org_id,
170 profile.org_kind,
171 paint.paint("queue:", Palette::label()),
172 profile.default_queue.as_deref().unwrap_or("-"),
173 );
174
175 let code = report_profile(
176 profile,
177 brief,
178 paint,
179 name,
180 &mut queues_seen,
181 &mut out,
182 &mut err,
183 )
184 .await;
185 if code == ExitCode::Success {
186 any_success = true;
187 } else {
188 last_failure = Some(code);
189 if is_active {
190 active_failure = Some(code);
191 }
192 }
193 }
194
195 remember_queues(session, brief, active_only, active.as_deref(), &queues_seen);
196 warn_about_collisions(session, paint, &queues_seen);
197
198 if secrets::overridden() && session.config.profiles.len() > 1 {
203 let _ = writeln!(
204 err,
205 "{} YTCLI_TOKEN is set, so every profile above was read through that one token, whatever account it names",
206 paint.paint("warning:", Palette::warn()),
207 );
208 }
209
210 if session.config.profiles.len() > 1 {
213 let _ = writeln!(
214 err,
215 "{}",
216 paint.paint(
217 "change the default with: ytcli auth use <profile>",
218 Palette::label()
219 )
220 );
221 }
222
223 active_failure
227 .or_else(|| (!any_success).then_some(last_failure).flatten())
228 .unwrap_or(ExitCode::Success)
229}
230
231fn remember_queues(
233 session: &Session,
234 brief: bool,
235 active_only: bool,
236 active: Option<&str>,
237 queues_seen: &std::collections::BTreeMap<String, Vec<String>>,
238) {
239 if brief {
240 return;
241 }
242
243 let cache_path = crate::config::cache::path_for(&session.config_file);
244 let mut cache = crate::config::cache::Cache::load(&cache_path);
245
246 for name in session
247 .config
248 .profiles
249 .keys()
250 .filter(|name| !active_only || active == Some(name.as_str()))
251 {
252 let keys: Vec<String> = queues_seen
253 .iter()
254 .filter(|(_, profiles)| profiles.iter().any(|profile| profile == name))
255 .map(|(key, _)| key.clone())
256 .collect();
257 cache.record(name, &keys);
258 }
259
260 cache.save(&cache_path);
261}
262
263fn report_sources(session: &Session, paint: Painter, out: &mut impl std::io::Write) {
274 let from = match std::env::var("YTCLI_CONFIG") {
275 Ok(path) if session.config_file == std::path::Path::new(&path) => "from YTCLI_CONFIG",
276 _ if session.global.config.is_some() => "from --config",
277 _ => "default location",
278 };
279
280 let _ = writeln!(
281 out,
282 "{} {} ({})",
283 paint.paint("config:", Palette::label()),
284 session.config_file.display(),
285 paint.paint(from, Palette::label()),
286 );
287
288 let mut overriding: Vec<String> = std::env::vars()
292 .map(|(name, _)| name)
293 .filter(|name| name.starts_with("YTCLI_") && !name.is_empty())
294 .collect();
295 overriding.sort();
296
297 if !overriding.is_empty() {
298 let _ = writeln!(
299 out,
300 "{} {}",
301 paint.paint("environment:", Palette::label()),
302 overriding.join(", "),
303 );
304 }
305}
306
307fn warn_about_collisions(
317 session: &Session,
318 paint: Painter,
319 queues_seen: &std::collections::BTreeMap<String, Vec<String>>,
320) {
321 let mut err = anstream::stderr();
322
323 let organisation = |name: &str| {
324 session
325 .config
326 .profiles
327 .get(name)
328 .map(|profile| profile.org_id.clone())
329 };
330
331 let ambiguous: Vec<(&String, &Vec<String>)> = queues_seen
332 .iter()
333 .filter(|(_, profiles)| {
334 profiles.len() > 1
335 && profiles
336 .iter()
337 .filter_map(|name| organisation(name))
338 .collect::<std::collections::BTreeSet<_>>()
339 .len()
340 > 1
341 })
342 .collect();
343 if ambiguous.is_empty() {
344 return;
345 }
346
347 let _ = writeln!(err);
348 for (key, profiles) in ambiguous {
349 let _ = writeln!(
350 err,
351 "{} queue {key} is visible in {} — in different organisations, so a bare {key}-1 will be refused; write {}/{key}-1",
352 paint.paint("warning:", Palette::warn()),
353 profiles.join(" and "),
354 profiles.first().map_or("profile", String::as_str),
355 );
356 }
357}
358
359async fn report_profile(
361 profile: &crate::config::Profile,
362 brief: bool,
363 paint: Painter,
364 profile_name: &str,
365 queues_seen: &mut std::collections::BTreeMap<String, Vec<String>>,
366 out: &mut impl std::io::Write,
367 err: &mut impl std::io::Write,
368) -> ExitCode {
369 let (token, origin) = match secrets::token_from(&profile.account) {
370 Ok(pair) => pair,
371 Err(error) => {
372 let _ = writeln!(
373 out,
374 " {} {}",
375 paint.paint("token:", Palette::label()),
376 paint.paint("missing", Palette::bad())
377 );
378 let _ = writeln!(err, " {error}");
379 return ExitCode::Auth;
380 }
381 };
382
383 let mut config = ClientConfig::new(token, profile.org_id.clone(), profile.org_kind);
384 if let Ok(base) = std::env::var("YTCLI_BASE_URL") {
385 config.base_url = base;
386 }
387 let client = match Client::new(&config) {
388 Ok(client) => client,
389 Err(error) => {
390 let _ = writeln!(err, " {error}");
391 return error.exit_code();
392 }
393 };
394
395 let via = match origin {
399 secrets::Origin::Environment => " (from YTCLI_TOKEN)",
400 secrets::Origin::Keychain => " (from keychain)",
404 };
405
406 match client.myself().await {
407 Ok(user) => {
408 let _ = writeln!(
409 out,
410 " {} {}{via} {} {}{}",
411 paint.paint("token:", Palette::label()),
412 paint.paint("ok", Palette::ok()),
413 paint.paint("user:", Palette::label()),
414 user.login.as_deref().unwrap_or(&user.id),
415 user.display
416 .as_deref()
417 .map_or_else(String::new, |display| format!(" ({display})")),
418 );
419 }
420 Err(error) => {
421 let _ = writeln!(
422 out,
423 " {} {}",
424 paint.paint("token:", Palette::label()),
425 paint.paint("rejected", Palette::bad())
426 );
427 let _ = writeln!(err, " {error}");
428 if matches!(error, crate::api::error::ApiError::Unauthorized) {
429 let _ = writeln!(err, "\n{}", guidance::block(guidance::TOKEN));
430 }
431 return error.exit_code();
432 }
433 }
434
435 if brief {
436 return ExitCode::Success;
437 }
438
439 reach(&client, paint, profile_name, queues_seen, out).await;
440 ExitCode::Success
441}
442
443async fn reach(
448 client: &Client,
449 paint: Painter,
450 profile_name: &str,
451 queues_seen: &mut std::collections::BTreeMap<String, Vec<String>>,
452 out: &mut impl std::io::Write,
453) {
454 let queues = client.queues().await.ok();
455 let projects = client.entities("project", None, 1, 5).await.ok();
456 let goals = client.entities("goal", None, 1, 1).await.ok();
457 let mine = client
458 .count("Assignee: me() AND Resolution: empty()")
459 .await
460 .ok();
461
462 let _ = writeln!(
463 out,
464 " {} {} {} {} {} {} {} {}",
465 paint.paint("queues:", Palette::label()),
466 queues
467 .as_ref()
468 .map_or_else(|| "-".to_owned(), |queues| queues.len().to_string()),
469 paint.paint("projects:", Palette::label()),
470 projects.as_ref().map_or_else(|| "-".to_owned(), count_of),
471 paint.paint("goals:", Palette::label()),
472 goals.as_ref().map_or_else(|| "-".to_owned(), count_of),
473 paint.paint("my open issues:", Palette::label()),
474 mine.map_or_else(|| "-".to_owned(), |count| count.to_string()),
475 );
476
477 if let Some(projects) = projects.filter(|page| !page.items.is_empty()) {
478 let names: Vec<String> = projects
479 .items
480 .iter()
481 .map(|project| {
482 project.short_id.map_or_else(
483 || project.summary.clone(),
484 |id| format!("{} ({id})", project.summary),
485 )
486 })
487 .collect();
488 let more = projects
489 .total
490 .unwrap_or(names.len() as u64)
491 .saturating_sub(names.len() as u64);
492 let suffix = if more > 0 {
493 format!(", +{more} more")
494 } else {
495 String::new()
496 };
497 let _ = writeln!(
498 out,
499 " {} {}{suffix}",
500 paint.paint("projects:", Palette::label()),
501 names.join(", ")
502 );
503 }
504
505 if let Some(queues) = queues.filter(|queues| !queues.is_empty()) {
506 for queue in &queues {
507 queues_seen
508 .entry(queue.key.clone())
509 .or_default()
510 .push(profile_name.to_owned());
511 }
512
513 let keys: Vec<&str> = queues
514 .iter()
515 .take(8)
516 .map(|queue| queue.key.as_str())
517 .collect();
518 let more = queues.len().saturating_sub(keys.len());
519 let suffix = if more > 0 {
520 format!(", +{more} more")
521 } else {
522 String::new()
523 };
524 let _ = writeln!(
525 out,
526 " {} {}{suffix}",
527 paint.paint("queues:", Palette::label()),
528 keys.join(", ")
529 );
530 }
531}
532
533fn count_of<T>(page: &crate::api::models::Page<T>) -> String {
534 page.total
535 .map_or_else(|| page.items.len().to_string(), |total| total.to_string())
536}
537
538async fn login(args: &LoginArgs, session: &Session) -> ExitCode {
545 let interactive = wizard::is_interactive();
546 let mut err = anstream::stderr();
547
548 if interactive {
551 wizard::introduce();
552 }
553
554 let Identity {
555 account,
556 token,
557 org_id,
558 org_kind: verified,
559 } = match identity(args, session, interactive).await {
560 Ok(identity) => identity,
561 Err(code) => return code,
562 };
563
564 if session.global.dry_run {
565 let _ = writeln!(
566 err,
567 "dry run: would store a token for `{account}` in the OS keychain"
568 );
569 } else {
570 if let Err(error) = secrets::store(&account, &token) {
571 return report(&error, ExitCode::Auth);
572 }
573 let _ = writeln!(err, "stored a token for `{account}` in the OS keychain");
574 }
575
576 let Some(org_id) = org_id else {
577 let _ = writeln!(
578 err,
579 "no --org-id given, so no profile was written and nothing can be queried yet.\n"
580 );
581 let _ = writeln!(err, "{}", guidance::block(guidance::ORG));
582 let _ = writeln!(
583 err,
584 "\nThen: ytcli auth login --account {account} --org-id <id> [--queue <QUEUE>]"
585 );
586 return ExitCode::Success;
587 };
588
589 let org_kind = verified.unwrap_or(OrgKind::Cloud);
590
591 let shape = Shape {
592 account: &account,
593 token: &token,
594 org_id: &org_id,
595 org_kind,
596 interactive,
597 };
598 let (profile_name, profile, make_default) = match shape_profile(args, session, &shape).await {
599 Ok(shaped) => shaped,
600 Err(code) => return code,
601 };
602
603 if session.global.dry_run {
604 let _ = writeln!(
605 err,
606 "dry run: would write profile `{profile_name}` (account={}, org={}, {:?}{}) to {}",
607 profile.account,
608 profile.org_id,
609 profile.org_kind,
610 if make_default { ", default" } else { "" },
611 session.config_file.display(),
612 );
613 return ExitCode::Success;
614 }
615
616 match store::upsert(
617 &session.config_file,
618 &account,
619 None,
620 Some((&profile_name, &profile)),
621 make_default,
622 ) {
623 Ok(_) => {
624 let _ = writeln!(
625 err,
626 "wrote profile `{profile_name}` to {}{}",
627 session.config_file.display(),
628 if make_default { " (default)" } else { "" },
629 );
630 let _ = writeln!(err, "try it: ytcli auth status --active-only");
631 emit(&format!("{profile_name}\n"));
632 ExitCode::Success
633 }
634 Err(error) => report(&error, ExitCode::Failure),
635 }
636}
637
638struct Identity {
641 account: String,
642 token: String,
643 org_id: Option<String>,
644 org_kind: Option<OrgKind>,
646}
647
648async fn identity(
653 args: &LoginArgs,
654 session: &Session,
655 interactive: bool,
656) -> Result<Identity, ExitCode> {
657 let mut err = anstream::stderr();
658
659 let account = match args.account.clone() {
660 Some(account) => account,
661 None if interactive => {
662 let existing: Vec<String> = session.config.accounts.keys().cloned().collect();
663 wizard::account(&existing).map_err(|error| report(&error, error.exit_code()))?
664 }
665 None => {
666 return Err(report(
667 &"--account is required when not running in a terminal",
668 ExitCode::ConfirmationRequired,
669 ));
670 }
671 };
672
673 let token = read_token(&account, interactive)?;
674
675 let (org_id, org_kind) = match (&args.org_id, interactive) {
678 (Some(org_id), _) => (Some(org_id.clone()), args.org_kind),
679 (None, true) => wizard::organisation()
680 .map(|(id, kind)| (Some(id), kind))
681 .map_err(|error| report(&error, error.exit_code()))?,
682 (None, false) => (None, None),
683 };
684
685 let verified = match (&org_id, args.no_verify) {
686 (Some(org_id), false) => {
687 let (kind, who) = verify(&token, org_id, org_kind).await?;
688 let _ = writeln!(err, "verified as {who} in org {org_id} ({kind:?})");
689 Some(kind)
690 }
691 (Some(_), true) => Some(org_kind.unwrap_or(OrgKind::Cloud)),
692 (None, _) => None,
693 };
694
695 Ok(Identity {
696 account,
697 token,
698 org_id,
699 org_kind: verified,
700 })
701}
702
703struct Shape<'a> {
705 account: &'a str,
706 token: &'a str,
707 org_id: &'a str,
708 org_kind: OrgKind,
709 interactive: bool,
710}
711
712async fn shape_profile(
717 args: &LoginArgs,
718 session: &Session,
719 shape: &Shape<'_>,
720) -> Result<(String, Profile, bool), ExitCode> {
721 let profile_name = match args.profile.clone() {
722 Some(name) => name,
723 None if shape.interactive => {
724 wizard::profile(shape.account).map_err(|error| report(&error, error.exit_code()))?
725 }
726 None => shape.account.to_owned(),
727 };
728
729 let queue = match args.queue.clone() {
732 Some(queue) => Some(queue),
733 None if shape.interactive => {
734 let available = queue_keys(shape.token, shape.org_id, shape.org_kind).await;
735
736 if !session.global.dry_run {
740 let cache_path = crate::config::cache::path_for(&session.config_file);
741 let mut cache = crate::config::cache::Cache::load(&cache_path);
742 cache.record(&profile_name, &available);
743 cache.save(&cache_path);
744 }
745
746 wizard::queue(&available).map_err(|error| report(&error, error.exit_code()))?
747 }
748 None => None,
749 };
750
751 let current_default = session.config.default_profile.as_deref();
752 let make_default = if args.default || current_default.is_none() {
753 true
754 } else if shape.interactive {
755 wizard::make_default(&profile_name, current_default)
756 .map_err(|error| report(&error, error.exit_code()))?
757 } else {
758 false
759 };
760
761 Ok((
762 profile_name,
763 Profile {
764 account: shape.account.to_owned(),
765 org_id: shape.org_id.to_owned(),
766 org_kind: shape.org_kind,
767 default_queue: queue,
768 display: crate::config::Display::default(),
769 },
770 make_default,
771 ))
772}
773
774async fn queue_keys(token: &str, org_id: &str, kind: OrgKind) -> Vec<String> {
777 let mut config = ClientConfig::new(token.to_owned(), org_id.to_owned(), kind);
778 if let Ok(base) = std::env::var("YTCLI_BASE_URL") {
779 config.base_url = base;
780 }
781
782 let Ok(client) = Client::new(&config) else {
783 return Vec::new();
784 };
785
786 client.queues().await.map_or_else(
787 |_| Vec::new(),
788 |queues| queues.into_iter().map(|queue| queue.key).collect(),
789 )
790}
791
792fn read_token(account: &str, interactive: bool) -> Result<String, ExitCode> {
794 if interactive {
795 return wizard::token(account).map_err(|error| report(&error, error.exit_code()));
796 }
797
798 let mut piped = String::new();
799 std::io::Read::read_to_string(&mut std::io::stdin(), &mut piped)
800 .map_err(|error| report(&error, ExitCode::Failure))?;
801
802 let token = piped.trim().to_owned();
803 if token.is_empty() {
804 return Err(report(&"no token given", ExitCode::Auth));
805 }
806 Ok(token)
807}
808
809async fn verify(
817 token: &str,
818 org_id: &str,
819 kind: Option<OrgKind>,
820) -> Result<(OrgKind, String), ExitCode> {
821 let candidates: Vec<OrgKind> = match kind {
822 Some(kind) => vec![kind],
823 None => vec![OrgKind::Cloud, OrgKind::Yandex360],
824 };
825
826 let mut last: Option<crate::api::error::ApiError> = None;
827
828 for candidate in candidates {
829 let mut config = ClientConfig::new(token.to_owned(), org_id.to_owned(), candidate);
830 if let Ok(base) = std::env::var("YTCLI_BASE_URL") {
831 config.base_url = base;
832 }
833
834 let client = match Client::new(&config) {
835 Ok(client) => client,
836 Err(error) => {
837 let code = error.exit_code();
838 return Err(report(&error, code));
839 }
840 };
841
842 match client.myself().await {
843 Ok(user) => {
844 let who = user.login.or(user.display).unwrap_or(user.id);
845 return Ok((candidate, who));
846 }
847 Err(error @ crate::api::error::ApiError::Unauthorized) => {
850 let code = error.exit_code();
851 let reported = report(&error, code);
852 let mut err = anstream::stderr();
853 let _ = writeln!(err, "\n{}", guidance::block(guidance::TOKEN));
854 return Err(reported);
855 }
856 Err(error) => last = Some(error),
857 }
858 }
859
860 let error = last.unwrap_or(crate::api::error::ApiError::Forbidden);
861 let code = error.exit_code();
862 let reported = report(
863 &format!("{error} — checked both organisation header forms"),
864 code,
865 );
866 let mut err = anstream::stderr();
867 let _ = writeln!(err, "\n{}", guidance::block(guidance::ORG));
868 Err(reported)
869}
870
871fn use_profile(session: &Session, profile: &str) -> ExitCode {
877 let mut err = anstream::stderr();
878
879 if !session.config.profiles.contains_key(profile) {
880 let known: Vec<&str> = session.config.profiles.keys().map(String::as_str).collect();
881 return report(
882 &format!(
883 "no profile called `{profile}`; configured: {}",
884 if known.is_empty() {
885 "none — run `ytcli auth login`".to_owned()
886 } else {
887 known.join(", ")
888 }
889 ),
890 ExitCode::NotFound,
891 );
892 }
893
894 let previous = session.config.default_profile.clone();
895 if previous.as_deref() == Some(profile) {
896 let _ = writeln!(err, "`{profile}` is already the default profile");
897 return ExitCode::Success;
898 }
899
900 if session.global.dry_run {
901 let _ = writeln!(
902 err,
903 "dry run: would make `{profile}` the default profile in {}",
904 session.config_file.display()
905 );
906 return ExitCode::Success;
907 }
908
909 match store::set_default(&session.config_file, profile) {
910 Ok(_) => {
911 let _ = writeln!(
912 err,
913 "default profile: {} → {profile}",
914 previous.as_deref().unwrap_or("none"),
915 );
916 ExitCode::Success
917 }
918 Err(error) => report(&error, ExitCode::Failure),
919 }
920}
921
922fn logout(account: &str) -> ExitCode {
923 match secrets::forget(account) {
924 Ok(()) => {
925 let mut err = anstream::stderr();
926 let _ = writeln!(err, "forgot the token for `{account}`");
927 ExitCode::Success
928 }
929 Err(error) => report(&error, ExitCode::Auth),
930 }
931}
932
933fn list(session: &Session) -> ExitCode {
937 let mut out = String::with_capacity(256);
938
939 let active = session
940 .resolved
941 .as_ref()
942 .map(|resolved| resolved.name.clone());
943
944 for (name, account) in &session.config.accounts {
945 let _ = writeln!(
946 out,
947 "account {name} token: {} {}",
948 if secrets::is_stored(name) {
949 "stored"
950 } else {
951 "missing"
952 },
953 account.description.as_deref().unwrap_or(""),
954 );
955 }
956
957 for (name, profile) in &session.config.profiles {
958 let marks = [
959 (session.config.default_profile.as_deref() == Some(name.as_str())).then_some("default"),
960 (active.as_deref() == Some(name.as_str())).then_some("active"),
961 ];
962 let marks: Vec<&str> = marks.into_iter().flatten().collect();
963 let suffix = if marks.is_empty() {
964 String::new()
965 } else {
966 format!(" [{}]", marks.join(", "))
967 };
968
969 let _ = writeln!(
970 out,
971 "profile {name} account: {} org: {} ({:?}){suffix}",
972 profile.account, profile.org_id, profile.org_kind,
973 );
974 }
975
976 if out.is_empty() {
977 return report(
978 &"no accounts or profiles configured yet; see `ytcli auth login --help`",
979 ExitCode::Auth,
980 );
981 }
982
983 emit(&out);
984 ExitCode::Success
985}