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 note = resolved
451 .profile
452 .description
453 .as_deref()
454 .map_or_else(String::new, |description| format!(" — {description}"));
455
456 let mut err = anstream::stderr();
457 let _ = writeln!(
458 err,
459 "→ profile={} org={} (from {}){note}",
460 resolved.name, resolved.profile.org_id, resolved.source,
461 );
462 }
463
464 pub fn client(&self) -> Result<crate::api::Client, ExitCode> {
470 let resolved = self
471 .resolved()
472 .map_err(|error| report(&error, ExitCode::Auth))?;
473 let client = self.client_with(resolved)?;
474 self.announce(resolved);
475 Ok(client)
476 }
477
478 pub fn client_with(&self, resolved: &Resolved) -> Result<crate::api::Client, ExitCode> {
480 let token = crate::secrets::token(&resolved.profile.account)
481 .map_err(|error| report(&error, ExitCode::Auth))?;
482
483 let mut config = crate::api::ClientConfig::new(
484 token,
485 resolved.profile.org_id.clone(),
486 resolved.profile.org_kind,
487 );
488 if let Ok(base) = std::env::var("YTCLI_BASE_URL") {
491 config.base_url = base;
492 }
493
494 crate::api::Client::new(&config).map_err(|error| {
495 let code = error.exit_code();
496 report(&error, code)
497 })
498 }
499
500 #[must_use]
502 pub fn display(&self) -> crate::config::Display {
503 self.resolved
504 .as_ref()
505 .map(|r| r.profile.display.clone())
506 .unwrap_or_default()
507 }
508
509 #[must_use]
511 pub fn default_queue(&self) -> Option<&str> {
512 self.resolved.as_ref().and_then(|r| r.queue.as_deref())
513 }
514}
515
516pub fn report(error: &dyn std::fmt::Display, code: ExitCode) -> ExitCode {
518 let mut err = anstream::stderr();
519 let _ = writeln!(err, "error: {error}");
520 code
521}
522
523pub fn emit(text: &str) {
525 let mut out = anstream::stdout();
526 let _ = write!(out, "{text}");
527}
528
529#[must_use]
531pub fn render_context(global: &GlobalArgs, resolved: Option<&Resolved>) -> Context {
532 let display = resolved.map(|r| &r.profile.display);
533 let audience = Audience::detect();
534
535 let description_lines = if global.full {
540 None
541 } else {
542 match (audience, display) {
543 (Audience::Human, None) => None,
544 (Audience::Human, Some(display)) => display.description_lines_human,
545 (Audience::Machine, display) => Some(display.map_or(10, |d| d.description_lines)),
546 }
547 };
548
549 Context {
550 format: global
551 .format
552 .or_else(|| display.map(|d| d.format))
553 .unwrap_or_default(),
554 audience,
555 description_lines,
556 extra_fields: display.map(|d| d.extra_fields.clone()).unwrap_or_default(),
557 images: !global.no_images && display.is_none_or(|d| d.images),
560 inline: crate::render::image::Inline::default(),
561 width: match audience {
562 Audience::Human => terminal_width().clamp(40, 110),
566 Audience::Machine => 100,
570 },
571 }
572}
573
574pub(crate) fn terminal_width() -> usize {
580 const UNKNOWN: usize = 100;
581 match termimad::crossterm::terminal::size() {
582 Ok((cols, _)) if cols >= 20 => cols as usize,
583 _ => UNKNOWN,
584 }
585}
586
587#[must_use]
592pub fn not_implemented(what: &str) -> ExitCode {
593 let mut err = anstream::stderr();
594 let _ = writeln!(
595 err,
596 "`{what}` is not implemented in this build yet — see docs/TODO.md"
597 );
598 ExitCode::NotImplemented
599}