pub mod attachment;
pub mod auth;
pub mod board;
pub mod bulk;
pub mod cheatsheet;
pub mod component;
pub mod dict;
pub mod entity;
pub mod field;
pub mod goal;
pub mod guidance;
pub mod help;
pub mod issue;
pub mod link;
pub mod portfolio;
pub mod project;
pub mod queue;
pub mod sprint;
pub mod user;
pub mod wizard;
pub mod worklog;
pub mod write;
use std::io::Write;
use std::path::PathBuf;
use clap::{Args, Parser, Subcommand};
use crate::config::{Config, Resolved};
use crate::exit::ExitCode;
use crate::render::{Audience, Context, Format};
#[derive(Debug, Parser)]
#[command(name = "ytcli", version, about, long_about = help::md(help::ROOT))]
#[command(after_long_help = help::LINKS)]
#[command(propagate_version = true)]
#[command(term_width = 0)]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
#[command(flatten)]
pub global: GlobalArgs,
}
#[derive(Debug, Args, Clone)]
#[allow(clippy::struct_excessive_bools)]
pub struct GlobalArgs {
#[arg(long, short = 'p', global = true)]
pub profile: Option<String>,
#[arg(long, short = 'f', global = true, value_name = "FORMAT")]
pub format: Option<Format>,
#[arg(long, global = true)]
pub full: bool,
#[arg(long, global = true)]
pub yes: bool,
#[arg(long, global = true)]
pub dry_run: bool,
#[arg(long, short = 'v', global = true, action = clap::ArgAction::Count)]
pub verbose: u8,
#[arg(long, global = true)]
pub no_images: bool,
#[arg(long, global = true, env = "YTCLI_CONFIG", value_name = "PATH")]
pub config: Option<PathBuf>,
}
#[derive(Debug, Subcommand)]
pub enum Command {
#[command(subcommand)]
Auth(auth::AuthCommand),
#[command(subcommand)]
Issue(issue::IssueCommand),
#[command(subcommand)]
Queue(queue::QueueCommand),
#[command(subcommand)]
Board(board::BoardCommand),
#[command(subcommand)]
Sprint(sprint::SprintCommand),
#[command(subcommand)]
Worklog(worklog::WorklogCommand),
#[command(subcommand)]
User(user::UserCommand),
#[command(subcommand)]
Link(link::LinkCommand),
#[command(subcommand)]
Bulk(bulk::BulkCommand),
#[command(subcommand)]
Component(component::ComponentCommand),
#[command(subcommand)]
Dict(dict::DictCommand),
#[command(subcommand)]
Field(field::FieldCommand),
#[command(subcommand)]
Template(field::TemplateCommand),
#[command(subcommand)]
Project(project::ProjectCommand),
#[command(subcommand)]
Portfolio(portfolio::PortfolioCommand),
#[command(subcommand)]
Goal(goal::GoalCommand),
#[command(subcommand)]
Attachment(attachment::AttachmentCommand),
#[command(long_about = help::md(help::CHEATSHEET))]
Cheatsheet(cheatsheet::CheatsheetArgs),
#[command(long_about = help::md(help::COMPLETIONS))]
Completions {
#[arg(value_enum)]
shell: clap_complete::Shell,
},
}
#[derive(Debug)]
pub struct Session {
pub config: Config,
pub config_file: PathBuf,
pub resolved: Option<Resolved>,
pub render: Context,
pub global: GlobalArgs,
}
impl Session {
pub fn resolved(&self) -> Result<&Resolved, crate::config::ConfigError> {
self.resolved
.as_ref()
.ok_or(crate::config::ConfigError::NoProfile)
}
fn expanded(&self, target: &str) -> Result<String, ExitCode> {
let (prefix, number) = match target.split_once('/') {
Some((profile, key)) => (Some(profile), key),
None => (None, target),
};
if number.is_empty() || !number.bytes().all(|byte| byte.is_ascii_digit()) {
return Ok(target.to_owned());
}
let resolved = match prefix {
Some(profile) => self
.config
.resolve(Some(profile), None, std::path::Path::new("."))
.map_err(|error| report(&error, ExitCode::Auth))?,
None => self
.resolved()
.map_err(|error| report(&error, ExitCode::Auth))?
.clone(),
};
let Some(queue) = resolved
.queue
.as_deref()
.or(resolved.profile.default_queue.as_deref())
else {
return Err(report(
&format!(
"`{number}` is a number, not an issue key, and profile {} has no default queue \
to complete it with — write PROJ-{number}, or set one with `ytcli auth login`",
resolved.name
),
ExitCode::ConfirmationRequired,
));
};
Ok(match prefix {
Some(profile) => format!("{profile}/{queue}-{number}"),
None => format!("{queue}-{number}"),
})
}
pub async fn client_for(&self, target: &str) -> Result<(crate::api::Client, String), ExitCode> {
let (client, key, _) = self.routed(target).await?;
Ok((client, key))
}
pub async fn routed(
&self,
target: &str,
) -> Result<(crate::api::Client, String, String), ExitCode> {
let target = &self.expanded(target)?;
let active = || {
self.resolved
.as_ref()
.map_or_else(|| "default".to_owned(), |resolved| resolved.name.clone())
};
let Some((profile, key)) = target.split_once('/') else {
if let Some(owner) = self.owner_of(target).await? {
let client = self.client_with(&owner)?;
self.announce(&owner);
let name = owner.name.clone();
return Ok((client, target.to_owned(), name));
}
return Ok((self.client()?, target.to_owned(), active()));
};
if profile.is_empty() || key.is_empty() {
return Err(report(
&format!("`{target}` is not a valid key; write it as PROJ-1 or profile/PROJ-1"),
ExitCode::ConfirmationRequired,
));
}
let mut resolved = self
.config
.resolve(Some(profile), None, std::path::Path::new("."))
.map_err(|error| report(&error, ExitCode::Auth))?;
resolved.source = crate::config::ProfileSource::Qualified(target.to_owned());
let client = self.client_with(&resolved)?;
self.announce(&resolved);
Ok((client, key.to_owned(), resolved.name))
}
async fn owner_of(&self, key: &str) -> Result<Option<Resolved>, ExitCode> {
if matches!(
self.resolved.as_ref().map(|resolved| &resolved.source),
Some(crate::config::ProfileSource::Flag)
) {
return Ok(None);
}
let Some(queue) = crate::config::cache::queue_of(key) else {
return Ok(None);
};
if self.config.profiles.len() < 2 {
return Ok(None);
}
let mut owners = self.owners_of(queue);
if owners.is_empty() {
self.learn_which_profile_sees_what().await;
owners = self.owners_of(queue);
}
let organisations: std::collections::BTreeSet<&str> = owners
.iter()
.filter_map(|name| self.config.profiles.get(name))
.map(|profile| profile.org_id.as_str())
.collect();
if organisations.len() > 1 {
let qualified = owners
.iter()
.map(|profile| format!("{profile}/{key}"))
.collect::<Vec<_>>()
.join(" or ");
return Err(report(
&format!(
"`{key}` is ambiguous: queue {queue} is visible in {}, in different organisations — write {qualified}",
owners.join(" and "),
),
ExitCode::ConfirmationRequired,
));
}
let active = self
.resolved
.as_ref()
.map(|resolved| resolved.name.as_str());
if owners.is_empty() || owners.iter().any(|owner| Some(owner.as_str()) == active) {
return Ok(None);
}
let name = owners.first().cloned().unwrap_or_default();
let mut resolved = self
.config
.resolve(Some(&name), None, std::path::Path::new("."))
.map_err(|error| report(&error, ExitCode::Auth))?;
resolved.source = crate::config::ProfileSource::QueueOwner(queue.to_owned());
Ok(Some(resolved))
}
fn owners_of(&self, queue: &str) -> Vec<String> {
let configured: Vec<String> = self.config.profiles.keys().cloned().collect();
crate::config::cache::Cache::load(&crate::config::cache::path_for(&self.config_file))
.profiles_for(queue, &configured)
}
async fn learn_which_profile_sees_what(&self) {
let mut err = anstream::stderr();
let _ = writeln!(
err,
"→ asking each profile which queues it can see (once; remembered afterwards)"
);
let path = crate::config::cache::path_for(&self.config_file);
let mut cache = crate::config::cache::Cache::load(&path);
let names: Vec<String> = self.config.profiles.keys().cloned().collect();
for name in names {
let Ok(resolved) = self
.config
.resolve(Some(&name), None, std::path::Path::new("."))
else {
continue;
};
let Ok(token) = crate::secrets::token(&resolved.profile.account) else {
continue;
};
let mut config = crate::api::ClientConfig::new(
token,
resolved.profile.org_id.clone(),
resolved.profile.org_kind,
);
if let Ok(base) = std::env::var("YTCLI_BASE_URL") {
config.base_url = base;
}
let Ok(client) = crate::api::Client::new(&config) else {
continue;
};
let queues = client.queues().await.unwrap_or_default();
if queues.is_empty() {
continue;
}
let keys: Vec<String> = queues.into_iter().map(|queue| queue.key).collect();
cache.record(&name, &keys);
}
cache.save(&path);
}
pub fn announce(&self, resolved: &Resolved) {
static SAID: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
if SAID.swap(true, std::sync::atomic::Ordering::Relaxed) {
return;
}
let mut err = anstream::stderr();
let _ = writeln!(
err,
"→ profile={} org={} (from {})",
resolved.name, resolved.profile.org_id, resolved.source,
);
}
pub fn client(&self) -> Result<crate::api::Client, ExitCode> {
let resolved = self
.resolved()
.map_err(|error| report(&error, ExitCode::Auth))?;
let client = self.client_with(resolved)?;
self.announce(resolved);
Ok(client)
}
pub fn client_with(&self, resolved: &Resolved) -> Result<crate::api::Client, ExitCode> {
let token = crate::secrets::token(&resolved.profile.account)
.map_err(|error| report(&error, ExitCode::Auth))?;
let mut config = crate::api::ClientConfig::new(
token,
resolved.profile.org_id.clone(),
resolved.profile.org_kind,
);
if let Ok(base) = std::env::var("YTCLI_BASE_URL") {
config.base_url = base;
}
crate::api::Client::new(&config).map_err(|error| {
let code = error.exit_code();
report(&error, code)
})
}
#[must_use]
pub fn display(&self) -> crate::config::Display {
self.resolved
.as_ref()
.map(|r| r.profile.display.clone())
.unwrap_or_default()
}
#[must_use]
pub fn default_queue(&self) -> Option<&str> {
self.resolved.as_ref().and_then(|r| r.queue.as_deref())
}
}
pub fn report(error: &dyn std::fmt::Display, code: ExitCode) -> ExitCode {
let mut err = anstream::stderr();
let _ = writeln!(err, "error: {error}");
code
}
pub fn emit(text: &str) {
let mut out = anstream::stdout();
let _ = write!(out, "{text}");
}
#[must_use]
pub fn render_context(global: &GlobalArgs, resolved: Option<&Resolved>) -> Context {
let display = resolved.map(|r| &r.profile.display);
let audience = Audience::detect();
let description_lines = if global.full {
None
} else {
match (audience, display) {
(Audience::Human, None) => None,
(Audience::Human, Some(display)) => display.description_lines_human,
(Audience::Machine, display) => Some(display.map_or(10, |d| d.description_lines)),
}
};
Context {
format: global
.format
.or_else(|| display.map(|d| d.format))
.unwrap_or_default(),
audience,
description_lines,
extra_fields: display.map(|d| d.extra_fields.clone()).unwrap_or_default(),
images: !global.no_images && display.is_none_or(|d| d.images),
inline: crate::render::image::Inline::default(),
width: match audience {
Audience::Human => terminal_width().clamp(40, 110),
Audience::Machine => 100,
},
}
}
pub(crate) fn terminal_width() -> usize {
const UNKNOWN: usize = 100;
match termimad::crossterm::terminal::size() {
Ok((cols, _)) if cols >= 20 => cols as usize,
_ => UNKNOWN,
}
}
#[must_use]
pub fn not_implemented(what: &str) -> ExitCode {
let mut err = anstream::stderr();
let _ = writeln!(
err,
"`{what}` is not implemented in this build yet — see docs/TODO.md"
);
ExitCode::NotImplemented
}