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 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/// Token-efficient Yandex Tracker CLI for humans and AI agents.
42#[derive(Debug, Parser)]
43#[command(name = "ytcli", version, about, long_about = help::md(help::ROOT))]
44// On the long help only, so `-h` stays the size it is: somebody who asked for
45// the short form asked for less, not for less of a different thing.
46#[command(after_long_help = help::LINKS)]
47#[command(propagate_version = true)]
48// Help is rendered markdown, and clap's own wrapping counts escape codes as
49// characters — it would cut a table in half and break an example mid-flag.
50// The text arrives already wrapped to the window.
51#[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/// Flags every command accepts.
61#[derive(Debug, Args, Clone)]
62// A command-line flag is a bool, and clap needs one field per flag. A state
63// machine here would be a fiction maintained for a lint.
64#[allow(clippy::struct_excessive_bools)]
65pub struct GlobalArgs {
66    // `YTCLI_PROFILE` is read separately rather than through clap's `env`, so
67    // that `auth status` can report which of the two the value came from. That
68    // is a fact about the implementation, so it stays out of the help text,
69    // which is reprinted under every command.
70    /// Act as this profile; overrides `YTCLI_PROFILE` and `.tracker.toml`.
71    #[arg(long, short = 'p', global = true)]
72    pub profile: Option<String>,
73
74    /// text (compact, default), json (our schema), json-raw, toon.
75    #[arg(long, short = 'f', global = true, value_name = "FORMAT")]
76    pub format: Option<Format>,
77
78    /// Print the whole description, however long.
79    #[arg(long, global = true)]
80    pub full: bool,
81
82    /// Confirm a change that touches more than one issue.
83    #[arg(long, global = true)]
84    pub yes: bool,
85
86    /// Print the request that would be sent, and send nothing.
87    #[arg(long, global = true)]
88    pub dry_run: bool,
89
90    /// Log to stderr; repeat for more. stdout stays pipeable.
91    #[arg(long, short = 'v', global = true, action = clap::ArgAction::Count)]
92    pub verbose: u8,
93
94    /// Do not draw image attachments, even where the terminal could.
95    #[arg(long, global = true)]
96    pub no_images: bool,
97
98    /// Config file to use instead of the per-user one.
99    ///
100    /// Also read from `YTCLI_CONFIG`, which is how a test or a container points
101    /// the tool at a config without rewriting every documented command line.
102    #[arg(long, global = true, env = "YTCLI_CONFIG", value_name = "PATH")]
103    pub config: Option<PathBuf>,
104}
105
106/// Top-level command groups, one per entity.
107#[derive(Debug, Subcommand)]
108pub enum Command {
109    /// Accounts, organisations and who you currently are.
110    #[command(subcommand)]
111    Auth(auth::AuthCommand),
112    /// Issues: read, search and change.
113    #[command(subcommand)]
114    Issue(issue::IssueCommand),
115    /// Queues and their fields.
116    #[command(subcommand)]
117    Queue(queue::QueueCommand),
118    /// Boards and their sprints.
119    #[command(subcommand)]
120    Board(board::BoardCommand),
121    /// Sprints, across every board.
122    #[command(subcommand)]
123    Sprint(sprint::SprintCommand),
124    /// Time logged across issues.
125    #[command(subcommand)]
126    Worklog(worklog::WorklogCommand),
127    /// People in the organisation.
128    #[command(subcommand)]
129    User(user::UserCommand),
130    /// The kinds of link two issues can have.
131    #[command(subcommand)]
132    Link(link::LinkCommand),
133    /// Bulk changes Tracker is running, or has run.
134    #[command(subcommand)]
135    Bulk(bulk::BulkCommand),
136    /// Components: the parts a queue splits its work by.
137    #[command(subcommand)]
138    Component(component::ComponentCommand),
139    /// The values issues can take: types, priorities, statuses, resolutions.
140    #[command(subcommand)]
141    Dict(dict::DictCommand),
142    /// Fields defined across the organisation.
143    #[command(subcommand)]
144    Field(field::FieldCommand),
145    /// Issue and comment templates.
146    #[command(subcommand)]
147    Template(field::TemplateCommand),
148    /// Projects.
149    #[command(subcommand)]
150    Project(project::ProjectCommand),
151    /// Portfolios: projects and portfolios grouped together.
152    #[command(subcommand)]
153    Portfolio(portfolio::PortfolioCommand),
154    /// Goals.
155    #[command(subcommand)]
156    Goal(goal::GoalCommand),
157    /// Issue attachments.
158    #[command(subcommand)]
159    Attachment(attachment::AttachmentCommand),
160    /// Yandex Wiki pages, read through the same profile.
161    #[command(subcommand)]
162    Wiki(wiki::WikiCommand),
163    /// Print a compact reference of the whole CLI, for agents.
164    #[command(long_about = help::md(help::CHEATSHEET))]
165    Cheatsheet(cheatsheet::CheatsheetArgs),
166    /// Generate a shell completion script.
167    #[command(long_about = help::md(help::COMPLETIONS))]
168    Completions {
169        /// Shell to generate for.
170        #[arg(value_enum)]
171        shell: clap_complete::Shell,
172    },
173}
174
175/// Everything a command implementation needs, assembled once.
176#[derive(Debug)]
177pub struct Session {
178    pub config: Config,
179    /// Where `config` came from, so `auth login` can write back to it.
180    pub config_file: PathBuf,
181    pub resolved: Option<Resolved>,
182    pub render: Context,
183    pub global: GlobalArgs,
184}
185
186impl Session {
187    /// The active profile, or an auth error explaining how to get one.
188    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    /// A bare issue number completed with the default queue of its profile.
195    ///
196    /// Everything else is returned as given: this only ever fires on digits,
197    /// which no queue key can be, so nothing that already worked changes
198    /// meaning.
199    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        // A pinned repository's queue wins, the way it does everywhere else: the
220        // checkout says what work is being done here.
221        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    /// Split a possibly profile-qualified target and build the client for it.
243    ///
244    /// Queue keys are only unique **inside** an organisation: two profiles can
245    /// both see a `LMS`, and `LMS-12` then names two different issues. So the
246    /// key decides the profile, in this order:
247    ///
248    /// 1. `work/LMS-12` says which, and is always obeyed.
249    /// 2. Otherwise the profile that can see queue `LMS` is used, even when it
250    ///    is not the default one. Sending the request to a profile known not to
251    ///    have the queue only produces a 403 that reads like a rights problem.
252    /// 3. Two profiles in *different* organisations seeing one queue key is the
253    ///    genuinely ambiguous case, and is refused rather than guessed at.
254    /// 4. A bare number is the issue's number in the profile's default queue.
255    ///    `42` and `PROJ-42` then name the same issue, which is what somebody
256    ///    reading a board and typing a key by hand actually has in front of
257    ///    them. Without a default queue it is refused: there is nothing to
258    ///    complete it with, and a number is not a key.
259    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    /// [`Self::client_for`], and the name of the profile that answered.
265    ///
266    /// The name is what a person recognises; the organisation the client
267    /// carries is what two profiles onto the same Tracker have in common. A
268    /// command that has to remember something about "where this went" wants
269    /// both.
270    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        // A slash with nothing useful around it is a typo, not a qualifier.
291        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    /// The profile that can see the queue this key belongs to.
310    ///
311    /// `None` means "no reason to leave the active profile": the key names no
312    /// queue, nothing is known about it, or the active profile is one of the
313    /// profiles that can see it.
314    async fn owner_of(&self, key: &str) -> Result<Option<Resolved>, ExitCode> {
315        // `--profile` is an instruction for this command, not a default, so it
316        // is never overridden by what a key implies. Everything else — the
317        // environment, a project pin, the configured default — is a standing
318        // choice that a key naming somebody else's queue can outvote.
319        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            // Nothing known, and more than one profile to be wrong about. One
336            // request per profile, once, is cheaper than a 403 the caller has
337            // to interpret — and it is remembered afterwards.
338            self.learn_which_profile_sees_what().await;
339            owners = self.owners_of(queue);
340        }
341
342        // Same organisation through two accounts is not ambiguity: `LMS-12`
343        // means one issue, and either profile fetches it.
344        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    /// Ask every profile which queues it can see, and remember the answers.
389    ///
390    /// Best-effort throughout: a profile whose token is missing or whose
391    /// organisation refuses is skipped, because the question being answered is
392    /// "who can see this queue", and a profile that cannot answer is not it.
393    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    /// Say which profile and organisation this answer came from.
439    ///
440    /// Once per run, on stderr. Every command says it, not only the writes: an
441    /// answer from the wrong organisation looks exactly like an answer from the
442    /// right one, and "which profile was that" should never be a question the
443    /// output leaves open. stderr because stdout is the data channel.
444    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        // The note, when there is one, is the half of this line a person can
451        // read: `org=12345` identifies nothing until you already know the
452        // number, and "which organisation is this about to write to" is the
453        // question the banner exists to answer.
454        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    /// Build an API client for the active profile.
469    ///
470    /// Every failure on the way here — no profile, no stored token, a token the
471    /// keychain will not release — is an auth problem from the caller's point of
472    /// view, and reports as one.
473    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    /// A client for a specific profile.
483    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        // Pointing the client at a stub server is how the CLI is tested end to
493        // end; nothing else should be setting this.
494        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    /// Display defaults for the active profile, or the built-in ones.
508    #[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    /// The queue to act on when the command did not name one.
517    #[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
523/// Print an error to stderr and hand back the exit code to return.
524pub 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
530/// Write rendered output to stdout.
531pub fn emit(text: &str) {
532    let mut out = anstream::stdout();
533    let _ = write!(out, "{text}");
534}
535
536/// Build the rendering context from flags, profile defaults and the terminal.
537#[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    // Truncation exists to save an agent's context, and a person reading their
543    // own terminal has none of that problem — being handed two thirds of a
544    // description and a note about the rest is just an extra command to type.
545    // A terminal therefore gets everything unless the profile says otherwise.
546    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        // The flag only ever turns images off. There is nothing to turn on: a
565        // terminal that cannot draw is not persuaded by a configuration file.
566        images: !global.no_images && display.is_none_or(|d| d.images),
567        inline: crate::render::image::Inline::default(),
568        width: match audience {
569            // Prose is wrapped to the window, within reason: a full-width
570            // paragraph on an ultrawide monitor is unreadable, and a very narrow
571            // terminal cannot be helped.
572            Audience::Human => terminal_width().clamp(40, 110),
573            // A pipe gets one width forever. Making output depend on the window
574            // it was produced in would mean two runs of the same command
575            // disagree, which is the kind of drift a fixed shape forbids.
576            Audience::Machine => 100,
577        },
578    }
579}
580
581/// The terminal width, or a sane guess.
582///
583/// A pseudo-terminal with no size set reports zero columns rather than failing,
584/// and wrapping prose to that would be worse than not asking at all — anything
585/// implausibly narrow is treated as "unknown", not as the answer.
586pub(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/// Placeholder for a command that is declared but not built yet.
595///
596/// It exists so the command tree, its help text and the shell completions are
597/// real from the first commit; the implementations land behind them.
598#[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}