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 wiki;
28pub mod wizard;
29pub mod worklog;
30pub mod write;
31
32use std::io::Write;
33use std::path::PathBuf;
34
35use clap::{Args, Parser, Subcommand};
36
37use crate::config::{Config, Resolved};
38use crate::exit::ExitCode;
39use crate::render::{Audience, Context, Format};
40
41#[derive(Debug, Parser)]
43#[command(name = "ytcli", version, about, long_about = help::md(help::ROOT))]
44#[command(after_long_help = help::LINKS)]
47#[command(propagate_version = true)]
48#[command(term_width = 0)]
52pub struct Cli {
53 #[command(subcommand)]
54 pub command: Command,
55
56 #[command(flatten)]
57 pub global: GlobalArgs,
58}
59
60#[derive(Debug, Args, Clone)]
62#[allow(clippy::struct_excessive_bools)]
65pub struct GlobalArgs {
66 #[arg(long, short = 'p', global = true)]
72 pub profile: Option<String>,
73
74 #[arg(long, short = 'f', global = true, value_name = "FORMAT")]
76 pub format: Option<Format>,
77
78 #[arg(long, global = true)]
80 pub full: bool,
81
82 #[arg(long, global = true)]
84 pub yes: bool,
85
86 #[arg(long, global = true)]
88 pub dry_run: bool,
89
90 #[arg(long, short = 'v', global = true, action = clap::ArgAction::Count)]
92 pub verbose: u8,
93
94 #[arg(long, global = true)]
96 pub no_images: bool,
97
98 #[arg(long, global = true, env = "YTCLI_CONFIG", value_name = "PATH")]
103 pub config: Option<PathBuf>,
104}
105
106#[derive(Debug, Subcommand)]
108pub enum Command {
109 #[command(subcommand)]
111 Auth(auth::AuthCommand),
112 #[command(subcommand)]
114 Issue(issue::IssueCommand),
115 #[command(subcommand)]
117 Queue(queue::QueueCommand),
118 #[command(subcommand)]
120 Board(board::BoardCommand),
121 #[command(subcommand)]
123 Sprint(sprint::SprintCommand),
124 #[command(subcommand)]
126 Worklog(worklog::WorklogCommand),
127 #[command(subcommand)]
129 User(user::UserCommand),
130 #[command(subcommand)]
132 Link(link::LinkCommand),
133 #[command(subcommand)]
135 Bulk(bulk::BulkCommand),
136 #[command(subcommand)]
138 Component(component::ComponentCommand),
139 #[command(subcommand)]
141 Dict(dict::DictCommand),
142 #[command(subcommand)]
144 Field(field::FieldCommand),
145 #[command(subcommand)]
147 Template(field::TemplateCommand),
148 #[command(subcommand)]
150 Project(project::ProjectCommand),
151 #[command(subcommand)]
153 Portfolio(portfolio::PortfolioCommand),
154 #[command(subcommand)]
156 Goal(goal::GoalCommand),
157 #[command(subcommand)]
159 Attachment(attachment::AttachmentCommand),
160 #[command(subcommand)]
162 Wiki(wiki::WikiCommand),
163 #[command(long_about = help::md(help::CHEATSHEET))]
165 Cheatsheet(cheatsheet::CheatsheetArgs),
166 #[command(long_about = help::md(help::COMPLETIONS))]
168 Completions {
169 #[arg(value_enum)]
171 shell: clap_complete::Shell,
172 },
173}
174
175#[derive(Debug)]
177pub struct Session {
178 pub config: Config,
179 pub config_file: PathBuf,
181 pub resolved: Option<Resolved>,
182 pub render: Context,
183 pub global: GlobalArgs,
184}
185
186impl Session {
187 pub fn resolved(&self) -> Result<&Resolved, crate::config::ConfigError> {
189 self.resolved
190 .as_ref()
191 .ok_or(crate::config::ConfigError::NoProfile)
192 }
193
194 fn expanded(&self, target: &str) -> Result<String, ExitCode> {
200 let (prefix, number) = match target.split_once('/') {
201 Some((profile, key)) => (Some(profile), key),
202 None => (None, target),
203 };
204 if number.is_empty() || !number.bytes().all(|byte| byte.is_ascii_digit()) {
205 return Ok(target.to_owned());
206 }
207
208 let resolved = match prefix {
209 Some(profile) => self
210 .config
211 .resolve(Some(profile), None, std::path::Path::new("."))
212 .map_err(|error| report(&error, ExitCode::Auth))?,
213 None => self
214 .resolved()
215 .map_err(|error| report(&error, ExitCode::Auth))?
216 .clone(),
217 };
218
219 let Some(queue) = resolved
222 .queue
223 .as_deref()
224 .or(resolved.profile.default_queue.as_deref())
225 else {
226 return Err(report(
227 &format!(
228 "`{number}` is a number, not an issue key, and profile {} has no default queue \
229 to complete it with — write PROJ-{number}, or set one with `ytcli auth login`",
230 resolved.name
231 ),
232 ExitCode::ConfirmationRequired,
233 ));
234 };
235
236 Ok(match prefix {
237 Some(profile) => format!("{profile}/{queue}-{number}"),
238 None => format!("{queue}-{number}"),
239 })
240 }
241
242 pub async fn client_for(&self, target: &str) -> Result<(crate::api::Client, String), ExitCode> {
260 let (client, key, _) = self.routed(target).await?;
261 Ok((client, key))
262 }
263
264 pub async fn routed(
271 &self,
272 target: &str,
273 ) -> Result<(crate::api::Client, String, String), ExitCode> {
274 let target = &self.expanded(target)?;
275 let active = || {
276 self.resolved
277 .as_ref()
278 .map_or_else(|| "default".to_owned(), |resolved| resolved.name.clone())
279 };
280 let Some((profile, key)) = target.split_once('/') else {
281 if let Some(owner) = self.owner_of(target).await? {
282 let client = self.client_with(&owner)?;
283 self.announce(&owner);
284 let name = owner.name.clone();
285 return Ok((client, target.to_owned(), name));
286 }
287 return Ok((self.client()?, target.to_owned(), active()));
288 };
289
290 if profile.is_empty() || key.is_empty() {
292 return Err(report(
293 &format!("`{target}` is not a valid key; write it as PROJ-1 or profile/PROJ-1"),
294 ExitCode::ConfirmationRequired,
295 ));
296 }
297
298 let mut resolved = self
299 .config
300 .resolve(Some(profile), None, std::path::Path::new("."))
301 .map_err(|error| report(&error, ExitCode::Auth))?;
302 resolved.source = crate::config::ProfileSource::Qualified(target.to_owned());
303
304 let client = self.client_with(&resolved)?;
305 self.announce(&resolved);
306 Ok((client, key.to_owned(), resolved.name))
307 }
308
309 async fn owner_of(&self, key: &str) -> Result<Option<Resolved>, ExitCode> {
315 if matches!(
320 self.resolved.as_ref().map(|resolved| &resolved.source),
321 Some(crate::config::ProfileSource::Flag)
322 ) {
323 return Ok(None);
324 }
325
326 let Some(queue) = crate::config::cache::queue_of(key) else {
327 return Ok(None);
328 };
329 if self.config.profiles.len() < 2 {
330 return Ok(None);
331 }
332
333 let mut owners = self.owners_of(queue);
334 if owners.is_empty() {
335 self.learn_which_profile_sees_what().await;
339 owners = self.owners_of(queue);
340 }
341
342 let organisations: std::collections::BTreeSet<&str> = owners
345 .iter()
346 .filter_map(|name| self.config.profiles.get(name))
347 .map(|profile| profile.org_id.as_str())
348 .collect();
349
350 if organisations.len() > 1 {
351 let qualified = owners
352 .iter()
353 .map(|profile| format!("{profile}/{key}"))
354 .collect::<Vec<_>>()
355 .join(" or ");
356 return Err(report(
357 &format!(
358 "`{key}` is ambiguous: queue {queue} is visible in {}, in different organisations — write {qualified}",
359 owners.join(" and "),
360 ),
361 ExitCode::ConfirmationRequired,
362 ));
363 }
364
365 let active = self
366 .resolved
367 .as_ref()
368 .map(|resolved| resolved.name.as_str());
369 if owners.is_empty() || owners.iter().any(|owner| Some(owner.as_str()) == active) {
370 return Ok(None);
371 }
372
373 let name = owners.first().cloned().unwrap_or_default();
374 let mut resolved = self
375 .config
376 .resolve(Some(&name), None, std::path::Path::new("."))
377 .map_err(|error| report(&error, ExitCode::Auth))?;
378 resolved.source = crate::config::ProfileSource::QueueOwner(queue.to_owned());
379 Ok(Some(resolved))
380 }
381
382 fn owners_of(&self, queue: &str) -> Vec<String> {
383 let configured: Vec<String> = self.config.profiles.keys().cloned().collect();
384 crate::config::cache::Cache::load(&crate::config::cache::path_for(&self.config_file))
385 .profiles_for(queue, &configured)
386 }
387
388 async fn learn_which_profile_sees_what(&self) {
394 let mut err = anstream::stderr();
395 let _ = writeln!(
396 err,
397 "→ asking each profile which queues it can see (once; remembered afterwards)"
398 );
399
400 let path = crate::config::cache::path_for(&self.config_file);
401 let mut cache = crate::config::cache::Cache::load(&path);
402
403 let names: Vec<String> = self.config.profiles.keys().cloned().collect();
404 for name in names {
405 let Ok(resolved) = self
406 .config
407 .resolve(Some(&name), None, std::path::Path::new("."))
408 else {
409 continue;
410 };
411 let Ok(token) = crate::secrets::token(&resolved.profile.account) else {
412 continue;
413 };
414
415 let mut config = crate::api::ClientConfig::new(
416 token,
417 resolved.profile.org_id.clone(),
418 resolved.profile.org_kind,
419 );
420 if let Ok(base) = std::env::var("YTCLI_BASE_URL") {
421 config.base_url = base;
422 }
423 let Ok(client) = crate::api::Client::new(&config) else {
424 continue;
425 };
426
427 let queues = client.queues().await.unwrap_or_default();
428 if queues.is_empty() {
429 continue;
430 }
431 let keys: Vec<String> = queues.into_iter().map(|queue| queue.key).collect();
432 cache.record(&name, &keys);
433 }
434
435 cache.save(&path);
436 }
437
438 pub fn announce(&self, resolved: &Resolved) {
445 static SAID: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
446 if SAID.swap(true, std::sync::atomic::Ordering::Relaxed) {
447 return;
448 }
449
450 let note = resolved
455 .profile
456 .description
457 .as_deref()
458 .map_or_else(String::new, |description| format!(" — {description}"));
459
460 let mut err = anstream::stderr();
461 let _ = writeln!(
462 err,
463 "→ profile={} org={} (from {}){note}",
464 resolved.name, resolved.profile.org_id, resolved.source,
465 );
466 }
467
468 pub fn client(&self) -> Result<crate::api::Client, ExitCode> {
474 let resolved = self
475 .resolved()
476 .map_err(|error| report(&error, ExitCode::Auth))?;
477 let client = self.client_with(resolved)?;
478 self.announce(resolved);
479 Ok(client)
480 }
481
482 pub fn client_with(&self, resolved: &Resolved) -> Result<crate::api::Client, ExitCode> {
484 let token = crate::secrets::token(&resolved.profile.account)
485 .map_err(|error| report(&error, ExitCode::Auth))?;
486
487 let mut config = crate::api::ClientConfig::new(
488 token,
489 resolved.profile.org_id.clone(),
490 resolved.profile.org_kind,
491 );
492 if let Ok(base) = std::env::var("YTCLI_BASE_URL") {
495 config.base_url = base;
496 }
497 if let Ok(wiki) = std::env::var("YTCLI_WIKI_URL") {
498 config.wiki_url = wiki;
499 }
500
501 crate::api::Client::new(&config).map_err(|error| {
502 let code = error.exit_code();
503 report(&error, code)
504 })
505 }
506
507 #[must_use]
509 pub fn display(&self) -> crate::config::Display {
510 self.resolved
511 .as_ref()
512 .map(|r| r.profile.display.clone())
513 .unwrap_or_default()
514 }
515
516 #[must_use]
518 pub fn default_queue(&self) -> Option<&str> {
519 self.resolved.as_ref().and_then(|r| r.queue.as_deref())
520 }
521}
522
523pub fn report(error: &dyn std::fmt::Display, code: ExitCode) -> ExitCode {
525 let mut err = anstream::stderr();
526 let _ = writeln!(err, "error: {error}");
527 code
528}
529
530pub fn emit(text: &str) {
532 let mut out = anstream::stdout();
533 let _ = write!(out, "{text}");
534}
535
536#[must_use]
538pub fn render_context(global: &GlobalArgs, resolved: Option<&Resolved>) -> Context {
539 let display = resolved.map(|r| &r.profile.display);
540 let audience = Audience::detect();
541
542 let description_lines = if global.full {
547 None
548 } else {
549 match (audience, display) {
550 (Audience::Human, None) => None,
551 (Audience::Human, Some(display)) => display.description_lines_human,
552 (Audience::Machine, display) => Some(display.map_or(10, |d| d.description_lines)),
553 }
554 };
555
556 Context {
557 format: global
558 .format
559 .or_else(|| display.map(|d| d.format))
560 .unwrap_or_default(),
561 audience,
562 description_lines,
563 extra_fields: display.map(|d| d.extra_fields.clone()).unwrap_or_default(),
564 images: !global.no_images && display.is_none_or(|d| d.images),
567 inline: crate::render::image::Inline::default(),
568 width: match audience {
569 Audience::Human => terminal_width().clamp(40, 110),
573 Audience::Machine => 100,
577 },
578 }
579}
580
581pub(crate) fn terminal_width() -> usize {
587 const UNKNOWN: usize = 100;
588 match termimad::crossterm::terminal::size() {
589 Ok((cols, _)) if cols >= 20 => cols as usize,
590 _ => UNKNOWN,
591 }
592}
593
594#[must_use]
599pub fn not_implemented(what: &str) -> ExitCode {
600 let mut err = anstream::stderr();
601 let _ = writeln!(
602 err,
603 "`{what}` is not implemented in this build yet — see docs/TODO.md"
604 );
605 ExitCode::NotImplemented
606}