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(propagate_version = true)]
44#[command(term_width = 0)]
48pub struct Cli {
49 #[command(subcommand)]
50 pub command: Command,
51
52 #[command(flatten)]
53 pub global: GlobalArgs,
54}
55
56#[derive(Debug, Args, Clone)]
58#[allow(clippy::struct_excessive_bools)]
61pub struct GlobalArgs {
62 #[arg(long, short = 'p', global = true)]
68 pub profile: Option<String>,
69
70 #[arg(long, short = 'f', global = true, value_name = "FORMAT")]
72 pub format: Option<Format>,
73
74 #[arg(long, global = true)]
76 pub full: bool,
77
78 #[arg(long, global = true)]
80 pub yes: bool,
81
82 #[arg(long, global = true)]
84 pub dry_run: bool,
85
86 #[arg(long, short = 'v', global = true, action = clap::ArgAction::Count)]
88 pub verbose: u8,
89
90 #[arg(long, global = true)]
92 pub no_images: bool,
93
94 #[arg(long, global = true, env = "YTCLI_CONFIG", value_name = "PATH")]
99 pub config: Option<PathBuf>,
100}
101
102#[derive(Debug, Subcommand)]
104pub enum Command {
105 #[command(subcommand)]
107 Auth(auth::AuthCommand),
108 #[command(subcommand)]
110 Issue(issue::IssueCommand),
111 #[command(subcommand)]
113 Queue(queue::QueueCommand),
114 #[command(subcommand)]
116 Board(board::BoardCommand),
117 #[command(subcommand)]
119 Sprint(sprint::SprintCommand),
120 #[command(subcommand)]
122 Worklog(worklog::WorklogCommand),
123 #[command(subcommand)]
125 User(user::UserCommand),
126 #[command(subcommand)]
128 Link(link::LinkCommand),
129 #[command(subcommand)]
131 Bulk(bulk::BulkCommand),
132 #[command(subcommand)]
134 Component(component::ComponentCommand),
135 #[command(subcommand)]
137 Dict(dict::DictCommand),
138 #[command(subcommand)]
140 Field(field::FieldCommand),
141 #[command(subcommand)]
143 Template(field::TemplateCommand),
144 #[command(subcommand)]
146 Project(project::ProjectCommand),
147 #[command(subcommand)]
149 Portfolio(portfolio::PortfolioCommand),
150 #[command(subcommand)]
152 Goal(goal::GoalCommand),
153 #[command(subcommand)]
155 Attachment(attachment::AttachmentCommand),
156 #[command(long_about = help::md(help::CHEATSHEET))]
158 Cheatsheet(cheatsheet::CheatsheetArgs),
159 #[command(long_about = help::md(help::COMPLETIONS))]
161 Completions {
162 #[arg(value_enum)]
164 shell: clap_complete::Shell,
165 },
166}
167
168#[derive(Debug)]
170pub struct Session {
171 pub config: Config,
172 pub config_file: PathBuf,
174 pub resolved: Option<Resolved>,
175 pub render: Context,
176 pub global: GlobalArgs,
177}
178
179impl Session {
180 pub fn resolved(&self) -> Result<&Resolved, crate::config::ConfigError> {
182 self.resolved
183 .as_ref()
184 .ok_or(crate::config::ConfigError::NoProfile)
185 }
186
187 fn expanded(&self, target: &str) -> Result<String, ExitCode> {
193 let (prefix, number) = match target.split_once('/') {
194 Some((profile, key)) => (Some(profile), key),
195 None => (None, target),
196 };
197 if number.is_empty() || !number.bytes().all(|byte| byte.is_ascii_digit()) {
198 return Ok(target.to_owned());
199 }
200
201 let resolved = match prefix {
202 Some(profile) => self
203 .config
204 .resolve(Some(profile), None, std::path::Path::new("."))
205 .map_err(|error| report(&error, ExitCode::Auth))?,
206 None => self
207 .resolved()
208 .map_err(|error| report(&error, ExitCode::Auth))?
209 .clone(),
210 };
211
212 let Some(queue) = resolved
215 .queue
216 .as_deref()
217 .or(resolved.profile.default_queue.as_deref())
218 else {
219 return Err(report(
220 &format!(
221 "`{number}` is a number, not an issue key, and profile {} has no default queue \
222 to complete it with — write PROJ-{number}, or set one with `ytcli auth login`",
223 resolved.name
224 ),
225 ExitCode::ConfirmationRequired,
226 ));
227 };
228
229 Ok(match prefix {
230 Some(profile) => format!("{profile}/{queue}-{number}"),
231 None => format!("{queue}-{number}"),
232 })
233 }
234
235 pub async fn client_for(&self, target: &str) -> Result<(crate::api::Client, String), ExitCode> {
253 let (client, key, _) = self.routed(target).await?;
254 Ok((client, key))
255 }
256
257 pub async fn routed(
264 &self,
265 target: &str,
266 ) -> Result<(crate::api::Client, String, String), ExitCode> {
267 let target = &self.expanded(target)?;
268 let active = || {
269 self.resolved
270 .as_ref()
271 .map_or_else(|| "default".to_owned(), |resolved| resolved.name.clone())
272 };
273 let Some((profile, key)) = target.split_once('/') else {
274 if let Some(owner) = self.owner_of(target).await? {
275 let client = self.client_with(&owner)?;
276 self.announce(&owner);
277 let name = owner.name.clone();
278 return Ok((client, target.to_owned(), name));
279 }
280 return Ok((self.client()?, target.to_owned(), active()));
281 };
282
283 if profile.is_empty() || key.is_empty() {
285 return Err(report(
286 &format!("`{target}` is not a valid key; write it as PROJ-1 or profile/PROJ-1"),
287 ExitCode::ConfirmationRequired,
288 ));
289 }
290
291 let mut resolved = self
292 .config
293 .resolve(Some(profile), None, std::path::Path::new("."))
294 .map_err(|error| report(&error, ExitCode::Auth))?;
295 resolved.source = crate::config::ProfileSource::Qualified(target.to_owned());
296
297 let client = self.client_with(&resolved)?;
298 self.announce(&resolved);
299 Ok((client, key.to_owned(), resolved.name))
300 }
301
302 async fn owner_of(&self, key: &str) -> Result<Option<Resolved>, ExitCode> {
308 if matches!(
313 self.resolved.as_ref().map(|resolved| &resolved.source),
314 Some(crate::config::ProfileSource::Flag)
315 ) {
316 return Ok(None);
317 }
318
319 let Some(queue) = crate::config::cache::queue_of(key) else {
320 return Ok(None);
321 };
322 if self.config.profiles.len() < 2 {
323 return Ok(None);
324 }
325
326 let mut owners = self.owners_of(queue);
327 if owners.is_empty() {
328 self.learn_which_profile_sees_what().await;
332 owners = self.owners_of(queue);
333 }
334
335 let organisations: std::collections::BTreeSet<&str> = owners
338 .iter()
339 .filter_map(|name| self.config.profiles.get(name))
340 .map(|profile| profile.org_id.as_str())
341 .collect();
342
343 if organisations.len() > 1 {
344 let qualified = owners
345 .iter()
346 .map(|profile| format!("{profile}/{key}"))
347 .collect::<Vec<_>>()
348 .join(" or ");
349 return Err(report(
350 &format!(
351 "`{key}` is ambiguous: queue {queue} is visible in {}, in different organisations — write {qualified}",
352 owners.join(" and "),
353 ),
354 ExitCode::ConfirmationRequired,
355 ));
356 }
357
358 let active = self
359 .resolved
360 .as_ref()
361 .map(|resolved| resolved.name.as_str());
362 if owners.is_empty() || owners.iter().any(|owner| Some(owner.as_str()) == active) {
363 return Ok(None);
364 }
365
366 let name = owners.first().cloned().unwrap_or_default();
367 let mut resolved = self
368 .config
369 .resolve(Some(&name), None, std::path::Path::new("."))
370 .map_err(|error| report(&error, ExitCode::Auth))?;
371 resolved.source = crate::config::ProfileSource::QueueOwner(queue.to_owned());
372 Ok(Some(resolved))
373 }
374
375 fn owners_of(&self, queue: &str) -> Vec<String> {
376 let configured: Vec<String> = self.config.profiles.keys().cloned().collect();
377 crate::config::cache::Cache::load(&crate::config::cache::path_for(&self.config_file))
378 .profiles_for(queue, &configured)
379 }
380
381 async fn learn_which_profile_sees_what(&self) {
387 let mut err = anstream::stderr();
388 let _ = writeln!(
389 err,
390 "→ asking each profile which queues it can see (once; remembered afterwards)"
391 );
392
393 let path = crate::config::cache::path_for(&self.config_file);
394 let mut cache = crate::config::cache::Cache::load(&path);
395
396 let names: Vec<String> = self.config.profiles.keys().cloned().collect();
397 for name in names {
398 let Ok(resolved) = self
399 .config
400 .resolve(Some(&name), None, std::path::Path::new("."))
401 else {
402 continue;
403 };
404 let Ok(token) = crate::secrets::token(&resolved.profile.account) else {
405 continue;
406 };
407
408 let mut config = crate::api::ClientConfig::new(
409 token,
410 resolved.profile.org_id.clone(),
411 resolved.profile.org_kind,
412 );
413 if let Ok(base) = std::env::var("YTCLI_BASE_URL") {
414 config.base_url = base;
415 }
416 let Ok(client) = crate::api::Client::new(&config) else {
417 continue;
418 };
419
420 let queues = client.queues().await.unwrap_or_default();
421 if queues.is_empty() {
422 continue;
423 }
424 let keys: Vec<String> = queues.into_iter().map(|queue| queue.key).collect();
425 cache.record(&name, &keys);
426 }
427
428 cache.save(&path);
429 }
430
431 pub fn announce(&self, resolved: &Resolved) {
438 static SAID: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
439 if SAID.swap(true, std::sync::atomic::Ordering::Relaxed) {
440 return;
441 }
442
443 let mut err = anstream::stderr();
444 let _ = writeln!(
445 err,
446 "→ profile={} org={} (from {})",
447 resolved.name, resolved.profile.org_id, resolved.source,
448 );
449 }
450
451 pub fn client(&self) -> Result<crate::api::Client, ExitCode> {
457 let resolved = self
458 .resolved()
459 .map_err(|error| report(&error, ExitCode::Auth))?;
460 let client = self.client_with(resolved)?;
461 self.announce(resolved);
462 Ok(client)
463 }
464
465 pub fn client_with(&self, resolved: &Resolved) -> Result<crate::api::Client, ExitCode> {
467 let token = crate::secrets::token(&resolved.profile.account)
468 .map_err(|error| report(&error, ExitCode::Auth))?;
469
470 let mut config = crate::api::ClientConfig::new(
471 token,
472 resolved.profile.org_id.clone(),
473 resolved.profile.org_kind,
474 );
475 if let Ok(base) = std::env::var("YTCLI_BASE_URL") {
478 config.base_url = base;
479 }
480
481 crate::api::Client::new(&config).map_err(|error| {
482 let code = error.exit_code();
483 report(&error, code)
484 })
485 }
486
487 #[must_use]
489 pub fn display(&self) -> crate::config::Display {
490 self.resolved
491 .as_ref()
492 .map(|r| r.profile.display.clone())
493 .unwrap_or_default()
494 }
495
496 #[must_use]
498 pub fn default_queue(&self) -> Option<&str> {
499 self.resolved.as_ref().and_then(|r| r.queue.as_deref())
500 }
501}
502
503pub fn report(error: &dyn std::fmt::Display, code: ExitCode) -> ExitCode {
505 let mut err = anstream::stderr();
506 let _ = writeln!(err, "error: {error}");
507 code
508}
509
510pub fn emit(text: &str) {
512 let mut out = anstream::stdout();
513 let _ = write!(out, "{text}");
514}
515
516#[must_use]
518pub fn render_context(global: &GlobalArgs, resolved: Option<&Resolved>) -> Context {
519 let display = resolved.map(|r| &r.profile.display);
520 let audience = Audience::detect();
521
522 let description_lines = if global.full {
527 None
528 } else {
529 match (audience, display) {
530 (Audience::Human, None) => None,
531 (Audience::Human, Some(display)) => display.description_lines_human,
532 (Audience::Machine, display) => Some(display.map_or(10, |d| d.description_lines)),
533 }
534 };
535
536 Context {
537 format: global
538 .format
539 .or_else(|| display.map(|d| d.format))
540 .unwrap_or_default(),
541 audience,
542 description_lines,
543 extra_fields: display.map(|d| d.extra_fields.clone()).unwrap_or_default(),
544 images: !global.no_images && display.is_none_or(|d| d.images),
547 inline: crate::render::image::Inline::default(),
548 width: match audience {
549 Audience::Human => terminal_width().clamp(40, 110),
553 Audience::Machine => 100,
557 },
558 }
559}
560
561pub(crate) fn terminal_width() -> usize {
567 const UNKNOWN: usize = 100;
568 match termimad::crossterm::terminal::size() {
569 Ok((cols, _)) if cols >= 20 => cols as usize,
570 _ => UNKNOWN,
571 }
572}
573
574#[must_use]
579pub fn not_implemented(what: &str) -> ExitCode {
580 let mut err = anstream::stderr();
581 let _ = writeln!(
582 err,
583 "`{what}` is not implemented in this build yet — see docs/TODO.md"
584 );
585 ExitCode::NotImplemented
586}