Skip to main content

ytcli/cli/
mod.rs

1//! The command shell.
2//!
3//! The verb is the risk class. Read verbs (`get`, `find`, `list`, `status`) can
4//! never write, and there is no generic pass-through that would let one smuggle a
5//! change past that rule. That is what makes a permission allowlist like
6//! `ytcli issue get:*` meaningful for an agent host (`docs/adr/0001-security-model.md`).
7
8pub 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/// Token-efficient Yandex Tracker CLI for humans and AI agents.
41#[derive(Debug, Parser)]
42#[command(name = "ytcli", version, about, long_about = help::md(help::ROOT))]
43#[command(propagate_version = true)]
44// Help is rendered markdown, and clap's own wrapping counts escape codes as
45// characters — it would cut a table in half and break an example mid-flag.
46// The text arrives already wrapped to the window.
47#[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/// Flags every command accepts.
57#[derive(Debug, Args, Clone)]
58// A command-line flag is a bool, and clap needs one field per flag. A state
59// machine here would be a fiction maintained for a lint.
60#[allow(clippy::struct_excessive_bools)]
61pub struct GlobalArgs {
62    // `YTCLI_PROFILE` is read separately rather than through clap's `env`, so
63    // that `auth status` can report which of the two the value came from. That
64    // is a fact about the implementation, so it stays out of the help text,
65    // which is reprinted under every command.
66    /// Act as this profile; overrides `YTCLI_PROFILE` and `.tracker.toml`.
67    #[arg(long, short = 'p', global = true)]
68    pub profile: Option<String>,
69
70    /// text (compact, default), json (our schema), json-raw, toon.
71    #[arg(long, short = 'f', global = true, value_name = "FORMAT")]
72    pub format: Option<Format>,
73
74    /// Print the whole description, however long.
75    #[arg(long, global = true)]
76    pub full: bool,
77
78    /// Confirm a change that touches more than one issue.
79    #[arg(long, global = true)]
80    pub yes: bool,
81
82    /// Print the request that would be sent, and send nothing.
83    #[arg(long, global = true)]
84    pub dry_run: bool,
85
86    /// Log to stderr; repeat for more. stdout stays pipeable.
87    #[arg(long, short = 'v', global = true, action = clap::ArgAction::Count)]
88    pub verbose: u8,
89
90    /// Do not draw image attachments, even where the terminal could.
91    #[arg(long, global = true)]
92    pub no_images: bool,
93
94    /// Config file to use instead of the per-user one.
95    ///
96    /// Also read from `YTCLI_CONFIG`, which is how a test or a container points
97    /// the tool at a config without rewriting every documented command line.
98    #[arg(long, global = true, env = "YTCLI_CONFIG", value_name = "PATH")]
99    pub config: Option<PathBuf>,
100}
101
102/// Top-level command groups, one per entity.
103#[derive(Debug, Subcommand)]
104pub enum Command {
105    /// Accounts, organisations and who you currently are.
106    #[command(subcommand)]
107    Auth(auth::AuthCommand),
108    /// Issues: read, search and change.
109    #[command(subcommand)]
110    Issue(issue::IssueCommand),
111    /// Queues and their fields.
112    #[command(subcommand)]
113    Queue(queue::QueueCommand),
114    /// Boards and their sprints.
115    #[command(subcommand)]
116    Board(board::BoardCommand),
117    /// Sprints, across every board.
118    #[command(subcommand)]
119    Sprint(sprint::SprintCommand),
120    /// Time logged across issues.
121    #[command(subcommand)]
122    Worklog(worklog::WorklogCommand),
123    /// People in the organisation.
124    #[command(subcommand)]
125    User(user::UserCommand),
126    /// The kinds of link two issues can have.
127    #[command(subcommand)]
128    Link(link::LinkCommand),
129    /// Bulk changes Tracker is running, or has run.
130    #[command(subcommand)]
131    Bulk(bulk::BulkCommand),
132    /// Components: the parts a queue splits its work by.
133    #[command(subcommand)]
134    Component(component::ComponentCommand),
135    /// The values issues can take: types, priorities, statuses, resolutions.
136    #[command(subcommand)]
137    Dict(dict::DictCommand),
138    /// Fields defined across the organisation.
139    #[command(subcommand)]
140    Field(field::FieldCommand),
141    /// Issue and comment templates.
142    #[command(subcommand)]
143    Template(field::TemplateCommand),
144    /// Projects.
145    #[command(subcommand)]
146    Project(project::ProjectCommand),
147    /// Portfolios: projects and portfolios grouped together.
148    #[command(subcommand)]
149    Portfolio(portfolio::PortfolioCommand),
150    /// Goals.
151    #[command(subcommand)]
152    Goal(goal::GoalCommand),
153    /// Issue attachments.
154    #[command(subcommand)]
155    Attachment(attachment::AttachmentCommand),
156    /// Print a compact reference of the whole CLI, for agents.
157    #[command(long_about = help::md(help::CHEATSHEET))]
158    Cheatsheet(cheatsheet::CheatsheetArgs),
159    /// Generate a shell completion script.
160    #[command(long_about = help::md(help::COMPLETIONS))]
161    Completions {
162        /// Shell to generate for.
163        #[arg(value_enum)]
164        shell: clap_complete::Shell,
165    },
166}
167
168/// Everything a command implementation needs, assembled once.
169#[derive(Debug)]
170pub struct Session {
171    pub config: Config,
172    /// Where `config` came from, so `auth login` can write back to it.
173    pub config_file: PathBuf,
174    pub resolved: Option<Resolved>,
175    pub render: Context,
176    pub global: GlobalArgs,
177}
178
179impl Session {
180    /// The active profile, or an auth error explaining how to get one.
181    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    /// A bare issue number completed with the default queue of its profile.
188    ///
189    /// Everything else is returned as given: this only ever fires on digits,
190    /// which no queue key can be, so nothing that already worked changes
191    /// meaning.
192    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        // A pinned repository's queue wins, the way it does everywhere else: the
213        // checkout says what work is being done here.
214        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    /// Split a possibly profile-qualified target and build the client for it.
236    ///
237    /// Queue keys are only unique **inside** an organisation: two profiles can
238    /// both see a `LMS`, and `LMS-12` then names two different issues. So the
239    /// key decides the profile, in this order:
240    ///
241    /// 1. `work/LMS-12` says which, and is always obeyed.
242    /// 2. Otherwise the profile that can see queue `LMS` is used, even when it
243    ///    is not the default one. Sending the request to a profile known not to
244    ///    have the queue only produces a 403 that reads like a rights problem.
245    /// 3. Two profiles in *different* organisations seeing one queue key is the
246    ///    genuinely ambiguous case, and is refused rather than guessed at.
247    /// 4. A bare number is the issue's number in the profile's default queue.
248    ///    `42` and `PROJ-42` then name the same issue, which is what somebody
249    ///    reading a board and typing a key by hand actually has in front of
250    ///    them. Without a default queue it is refused: there is nothing to
251    ///    complete it with, and a number is not a key.
252    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    /// [`Self::client_for`], and the name of the profile that answered.
258    ///
259    /// The name is what a person recognises; the organisation the client
260    /// carries is what two profiles onto the same Tracker have in common. A
261    /// command that has to remember something about "where this went" wants
262    /// both.
263    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        // A slash with nothing useful around it is a typo, not a qualifier.
284        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    /// The profile that can see the queue this key belongs to.
303    ///
304    /// `None` means "no reason to leave the active profile": the key names no
305    /// queue, nothing is known about it, or the active profile is one of the
306    /// profiles that can see it.
307    async fn owner_of(&self, key: &str) -> Result<Option<Resolved>, ExitCode> {
308        // `--profile` is an instruction for this command, not a default, so it
309        // is never overridden by what a key implies. Everything else — the
310        // environment, a project pin, the configured default — is a standing
311        // choice that a key naming somebody else's queue can outvote.
312        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            // Nothing known, and more than one profile to be wrong about. One
329            // request per profile, once, is cheaper than a 403 the caller has
330            // to interpret — and it is remembered afterwards.
331            self.learn_which_profile_sees_what().await;
332            owners = self.owners_of(queue);
333        }
334
335        // Same organisation through two accounts is not ambiguity: `LMS-12`
336        // means one issue, and either profile fetches it.
337        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    /// Ask every profile which queues it can see, and remember the answers.
382    ///
383    /// Best-effort throughout: a profile whose token is missing or whose
384    /// organisation refuses is skipped, because the question being answered is
385    /// "who can see this queue", and a profile that cannot answer is not it.
386    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    /// Say which profile and organisation this answer came from.
432    ///
433    /// Once per run, on stderr. Every command says it, not only the writes: an
434    /// answer from the wrong organisation looks exactly like an answer from the
435    /// right one, and "which profile was that" should never be a question the
436    /// output leaves open. stderr because stdout is the data channel.
437    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    /// Build an API client for the active profile.
452    ///
453    /// Every failure on the way here — no profile, no stored token, a token the
454    /// keychain will not release — is an auth problem from the caller's point of
455    /// view, and reports as one.
456    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    /// A client for a specific profile.
466    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        // Pointing the client at a stub server is how the CLI is tested end to
476        // end; nothing else should be setting this.
477        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    /// Display defaults for the active profile, or the built-in ones.
488    #[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    /// The queue to act on when the command did not name one.
497    #[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
503/// Print an error to stderr and hand back the exit code to return.
504pub 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
510/// Write rendered output to stdout.
511pub fn emit(text: &str) {
512    let mut out = anstream::stdout();
513    let _ = write!(out, "{text}");
514}
515
516/// Build the rendering context from flags, profile defaults and the terminal.
517#[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    // Truncation exists to save an agent's context, and a person reading their
523    // own terminal has none of that problem — being handed two thirds of a
524    // description and a note about the rest is just an extra command to type.
525    // A terminal therefore gets everything unless the profile says otherwise.
526    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        // The flag only ever turns images off. There is nothing to turn on: a
545        // terminal that cannot draw is not persuaded by a configuration file.
546        images: !global.no_images && display.is_none_or(|d| d.images),
547        inline: crate::render::image::Inline::default(),
548        width: match audience {
549            // Prose is wrapped to the window, within reason: a full-width
550            // paragraph on an ultrawide monitor is unreadable, and a very narrow
551            // terminal cannot be helped.
552            Audience::Human => terminal_width().clamp(40, 110),
553            // A pipe gets one width forever. Making output depend on the window
554            // it was produced in would mean two runs of the same command
555            // disagree, which is the kind of drift a fixed shape forbids.
556            Audience::Machine => 100,
557        },
558    }
559}
560
561/// The terminal width, or a sane guess.
562///
563/// A pseudo-terminal with no size set reports zero columns rather than failing,
564/// and wrapping prose to that would be worse than not asking at all — anything
565/// implausibly narrow is treated as "unknown", not as the answer.
566pub(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/// Placeholder for a command that is declared but not built yet.
575///
576/// It exists so the command tree, its help text and the shell completions are
577/// real from the first commit; the implementations land behind them.
578#[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}