1pub mod attachment;
9pub mod auth;
10pub mod board;
11pub mod bulk;
12pub mod cheatsheet;
13pub mod component;
14pub mod dict;
15pub mod entity;
16pub mod field;
17pub mod goal;
18pub mod guidance;
19pub mod help;
20pub mod issue;
21pub mod link;
22pub mod portfolio;
23pub mod project;
24pub mod queue;
25pub mod sprint;
26pub mod user;
27pub mod wizard;
28pub mod worklog;
29pub mod write;
30
31use std::io::Write;
32use std::path::PathBuf;
33
34use clap::{Args, Parser, Subcommand};
35
36use crate::config::{Config, Resolved};
37use crate::exit::ExitCode;
38use crate::render::{Audience, Context, Format};
39
40#[derive(Debug, Parser)]
42#[command(name = "ytcli", version, about, long_about = help::md(help::ROOT))]
43#[command(after_long_help = help::LINKS)]
46#[command(propagate_version = true)]
47#[command(term_width = 0)]
51pub struct Cli {
52 #[command(subcommand)]
53 pub command: Command,
54
55 #[command(flatten)]
56 pub global: GlobalArgs,
57}
58
59#[derive(Debug, Args, Clone)]
61#[allow(clippy::struct_excessive_bools)]
64pub struct GlobalArgs {
65 #[arg(long, short = 'p', global = true)]
71 pub profile: Option<String>,
72
73 #[arg(long, short = 'f', global = true, value_name = "FORMAT")]
75 pub format: Option<Format>,
76
77 #[arg(long, global = true)]
79 pub full: bool,
80
81 #[arg(long, global = true)]
83 pub yes: bool,
84
85 #[arg(long, global = true)]
87 pub dry_run: bool,
88
89 #[arg(long, short = 'v', global = true, action = clap::ArgAction::Count)]
91 pub verbose: u8,
92
93 #[arg(long, global = true)]
95 pub no_images: bool,
96
97 #[arg(long, global = true, env = "YTCLI_CONFIG", value_name = "PATH")]
102 pub config: Option<PathBuf>,
103}
104
105#[derive(Debug, Subcommand)]
107pub enum Command {
108 #[command(subcommand)]
110 Auth(auth::AuthCommand),
111 #[command(subcommand)]
113 Issue(issue::IssueCommand),
114 #[command(subcommand)]
116 Queue(queue::QueueCommand),
117 #[command(subcommand)]
119 Board(board::BoardCommand),
120 #[command(subcommand)]
122 Sprint(sprint::SprintCommand),
123 #[command(subcommand)]
125 Worklog(worklog::WorklogCommand),
126 #[command(subcommand)]
128 User(user::UserCommand),
129 #[command(subcommand)]
131 Link(link::LinkCommand),
132 #[command(subcommand)]
134 Bulk(bulk::BulkCommand),
135 #[command(subcommand)]
137 Component(component::ComponentCommand),
138 #[command(subcommand)]
140 Dict(dict::DictCommand),
141 #[command(subcommand)]
143 Field(field::FieldCommand),
144 #[command(subcommand)]
146 Template(field::TemplateCommand),
147 #[command(subcommand)]
149 Project(project::ProjectCommand),
150 #[command(subcommand)]
152 Portfolio(portfolio::PortfolioCommand),
153 #[command(subcommand)]
155 Goal(goal::GoalCommand),
156 #[command(subcommand)]
158 Attachment(attachment::AttachmentCommand),
159 #[command(long_about = help::md(help::CHEATSHEET))]
161 Cheatsheet(cheatsheet::CheatsheetArgs),
162 #[command(long_about = help::md(help::COMPLETIONS))]
164 Completions {
165 #[arg(value_enum)]
167 shell: clap_complete::Shell,
168 },
169}
170
171#[derive(Debug)]
173pub struct Session {
174 pub config: Config,
175 pub config_file: PathBuf,
177 pub resolved: Option<Resolved>,
178 pub render: Context,
179 pub global: GlobalArgs,
180}
181
182impl Session {
183 pub fn resolved(&self) -> Result<&Resolved, crate::config::ConfigError> {
185 self.resolved
186 .as_ref()
187 .ok_or(crate::config::ConfigError::NoProfile)
188 }
189
190 fn expanded(&self, target: &str) -> Result<String, ExitCode> {
196 let (prefix, number) = match target.split_once('/') {
197 Some((profile, key)) => (Some(profile), key),
198 None => (None, target),
199 };
200 if number.is_empty() || !number.bytes().all(|byte| byte.is_ascii_digit()) {
201 return Ok(target.to_owned());
202 }
203
204 let resolved = match prefix {
205 Some(profile) => self
206 .config
207 .resolve(Some(profile), None, std::path::Path::new("."))
208 .map_err(|error| report(&error, ExitCode::Auth))?,
209 None => self
210 .resolved()
211 .map_err(|error| report(&error, ExitCode::Auth))?
212 .clone(),
213 };
214
215 let Some(queue) = resolved
218 .queue
219 .as_deref()
220 .or(resolved.profile.default_queue.as_deref())
221 else {
222 return Err(report(
223 &format!(
224 "`{number}` is a number, not an issue key, and profile {} has no default queue \
225 to complete it with — write PROJ-{number}, or set one with `ytcli auth login`",
226 resolved.name
227 ),
228 ExitCode::ConfirmationRequired,
229 ));
230 };
231
232 Ok(match prefix {
233 Some(profile) => format!("{profile}/{queue}-{number}"),
234 None => format!("{queue}-{number}"),
235 })
236 }
237
238 pub async fn client_for(&self, target: &str) -> Result<(crate::api::Client, String), ExitCode> {
256 let (client, key, _) = self.routed(target).await?;
257 Ok((client, key))
258 }
259
260 pub async fn routed(
267 &self,
268 target: &str,
269 ) -> Result<(crate::api::Client, String, String), ExitCode> {
270 let target = &self.expanded(target)?;
271 let active = || {
272 self.resolved
273 .as_ref()
274 .map_or_else(|| "default".to_owned(), |resolved| resolved.name.clone())
275 };
276 let Some((profile, key)) = target.split_once('/') else {
277 if let Some(owner) = self.owner_of(target).await? {
278 let client = self.client_with(&owner)?;
279 self.announce(&owner);
280 let name = owner.name.clone();
281 return Ok((client, target.to_owned(), name));
282 }
283 return Ok((self.client()?, target.to_owned(), active()));
284 };
285
286 if profile.is_empty() || key.is_empty() {
288 return Err(report(
289 &format!("`{target}` is not a valid key; write it as PROJ-1 or profile/PROJ-1"),
290 ExitCode::ConfirmationRequired,
291 ));
292 }
293
294 let mut resolved = self
295 .config
296 .resolve(Some(profile), None, std::path::Path::new("."))
297 .map_err(|error| report(&error, ExitCode::Auth))?;
298 resolved.source = crate::config::ProfileSource::Qualified(target.to_owned());
299
300 let client = self.client_with(&resolved)?;
301 self.announce(&resolved);
302 Ok((client, key.to_owned(), resolved.name))
303 }
304
305 async fn owner_of(&self, key: &str) -> Result<Option<Resolved>, ExitCode> {
311 if matches!(
316 self.resolved.as_ref().map(|resolved| &resolved.source),
317 Some(crate::config::ProfileSource::Flag)
318 ) {
319 return Ok(None);
320 }
321
322 let Some(queue) = crate::config::cache::queue_of(key) else {
323 return Ok(None);
324 };
325 if self.config.profiles.len() < 2 {
326 return Ok(None);
327 }
328
329 let mut owners = self.owners_of(queue);
330 if owners.is_empty() {
331 self.learn_which_profile_sees_what().await;
335 owners = self.owners_of(queue);
336 }
337
338 let organisations: std::collections::BTreeSet<&str> = owners
341 .iter()
342 .filter_map(|name| self.config.profiles.get(name))
343 .map(|profile| profile.org_id.as_str())
344 .collect();
345
346 if organisations.len() > 1 {
347 let qualified = owners
348 .iter()
349 .map(|profile| format!("{profile}/{key}"))
350 .collect::<Vec<_>>()
351 .join(" or ");
352 return Err(report(
353 &format!(
354 "`{key}` is ambiguous: queue {queue} is visible in {}, in different organisations — write {qualified}",
355 owners.join(" and "),
356 ),
357 ExitCode::ConfirmationRequired,
358 ));
359 }
360
361 let active = self
362 .resolved
363 .as_ref()
364 .map(|resolved| resolved.name.as_str());
365 if owners.is_empty() || owners.iter().any(|owner| Some(owner.as_str()) == active) {
366 return Ok(None);
367 }
368
369 let name = owners.first().cloned().unwrap_or_default();
370 let mut resolved = self
371 .config
372 .resolve(Some(&name), None, std::path::Path::new("."))
373 .map_err(|error| report(&error, ExitCode::Auth))?;
374 resolved.source = crate::config::ProfileSource::QueueOwner(queue.to_owned());
375 Ok(Some(resolved))
376 }
377
378 fn owners_of(&self, queue: &str) -> Vec<String> {
379 let configured: Vec<String> = self.config.profiles.keys().cloned().collect();
380 crate::config::cache::Cache::load(&crate::config::cache::path_for(&self.config_file))
381 .profiles_for(queue, &configured)
382 }
383
384 async fn learn_which_profile_sees_what(&self) {
390 let mut err = anstream::stderr();
391 let _ = writeln!(
392 err,
393 "→ asking each profile which queues it can see (once; remembered afterwards)"
394 );
395
396 let path = crate::config::cache::path_for(&self.config_file);
397 let mut cache = crate::config::cache::Cache::load(&path);
398
399 let names: Vec<String> = self.config.profiles.keys().cloned().collect();
400 for name in names {
401 let Ok(resolved) = self
402 .config
403 .resolve(Some(&name), None, std::path::Path::new("."))
404 else {
405 continue;
406 };
407 let Ok(token) = crate::secrets::token(&resolved.profile.account) else {
408 continue;
409 };
410
411 let mut config = crate::api::ClientConfig::new(
412 token,
413 resolved.profile.org_id.clone(),
414 resolved.profile.org_kind,
415 );
416 if let Ok(base) = std::env::var("YTCLI_BASE_URL") {
417 config.base_url = base;
418 }
419 let Ok(client) = crate::api::Client::new(&config) else {
420 continue;
421 };
422
423 let queues = client.queues().await.unwrap_or_default();
424 if queues.is_empty() {
425 continue;
426 }
427 let keys: Vec<String> = queues.into_iter().map(|queue| queue.key).collect();
428 cache.record(&name, &keys);
429 }
430
431 cache.save(&path);
432 }
433
434 pub fn announce(&self, resolved: &Resolved) {
441 static SAID: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
442 if SAID.swap(true, std::sync::atomic::Ordering::Relaxed) {
443 return;
444 }
445
446 let mut err = anstream::stderr();
447 let _ = writeln!(
448 err,
449 "→ profile={} org={} (from {})",
450 resolved.name, resolved.profile.org_id, resolved.source,
451 );
452 }
453
454 pub fn client(&self) -> Result<crate::api::Client, ExitCode> {
460 let resolved = self
461 .resolved()
462 .map_err(|error| report(&error, ExitCode::Auth))?;
463 let client = self.client_with(resolved)?;
464 self.announce(resolved);
465 Ok(client)
466 }
467
468 pub fn client_with(&self, resolved: &Resolved) -> Result<crate::api::Client, ExitCode> {
470 let token = crate::secrets::token(&resolved.profile.account)
471 .map_err(|error| report(&error, ExitCode::Auth))?;
472
473 let mut config = crate::api::ClientConfig::new(
474 token,
475 resolved.profile.org_id.clone(),
476 resolved.profile.org_kind,
477 );
478 if let Ok(base) = std::env::var("YTCLI_BASE_URL") {
481 config.base_url = base;
482 }
483
484 crate::api::Client::new(&config).map_err(|error| {
485 let code = error.exit_code();
486 report(&error, code)
487 })
488 }
489
490 #[must_use]
492 pub fn display(&self) -> crate::config::Display {
493 self.resolved
494 .as_ref()
495 .map(|r| r.profile.display.clone())
496 .unwrap_or_default()
497 }
498
499 #[must_use]
501 pub fn default_queue(&self) -> Option<&str> {
502 self.resolved.as_ref().and_then(|r| r.queue.as_deref())
503 }
504}
505
506pub fn report(error: &dyn std::fmt::Display, code: ExitCode) -> ExitCode {
508 let mut err = anstream::stderr();
509 let _ = writeln!(err, "error: {error}");
510 code
511}
512
513pub fn emit(text: &str) {
515 let mut out = anstream::stdout();
516 let _ = write!(out, "{text}");
517}
518
519#[must_use]
521pub fn render_context(global: &GlobalArgs, resolved: Option<&Resolved>) -> Context {
522 let display = resolved.map(|r| &r.profile.display);
523 let audience = Audience::detect();
524
525 let description_lines = if global.full {
530 None
531 } else {
532 match (audience, display) {
533 (Audience::Human, None) => None,
534 (Audience::Human, Some(display)) => display.description_lines_human,
535 (Audience::Machine, display) => Some(display.map_or(10, |d| d.description_lines)),
536 }
537 };
538
539 Context {
540 format: global
541 .format
542 .or_else(|| display.map(|d| d.format))
543 .unwrap_or_default(),
544 audience,
545 description_lines,
546 extra_fields: display.map(|d| d.extra_fields.clone()).unwrap_or_default(),
547 images: !global.no_images && display.is_none_or(|d| d.images),
550 inline: crate::render::image::Inline::default(),
551 width: match audience {
552 Audience::Human => terminal_width().clamp(40, 110),
556 Audience::Machine => 100,
560 },
561 }
562}
563
564pub(crate) fn terminal_width() -> usize {
570 const UNKNOWN: usize = 100;
571 match termimad::crossterm::terminal::size() {
572 Ok((cols, _)) if cols >= 20 => cols as usize,
573 _ => UNKNOWN,
574 }
575}
576
577#[must_use]
582pub fn not_implemented(what: &str) -> ExitCode {
583 let mut err = anstream::stderr();
584 let _ = writeln!(
585 err,
586 "`{what}` is not implemented in this build yet — see docs/TODO.md"
587 );
588 ExitCode::NotImplemented
589}