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// On the long help only, so `-h` stays the size it is: somebody who asked for
44// the short form asked for less, not for less of a different thing.
45#[command(after_long_help = help::LINKS)]
46#[command(propagate_version = true)]
47// Help is rendered markdown, and clap's own wrapping counts escape codes as
48// characters — it would cut a table in half and break an example mid-flag.
49// The text arrives already wrapped to the window.
50#[command(term_width = 0)]
51pub struct Cli {
52    #[command(subcommand)]
53    pub command: Command,
54
55    #[command(flatten)]
56    pub global: GlobalArgs,
57}
58
59/// Flags every command accepts.
60#[derive(Debug, Args, Clone)]
61// A command-line flag is a bool, and clap needs one field per flag. A state
62// machine here would be a fiction maintained for a lint.
63#[allow(clippy::struct_excessive_bools)]
64pub struct GlobalArgs {
65    // `YTCLI_PROFILE` is read separately rather than through clap's `env`, so
66    // that `auth status` can report which of the two the value came from. That
67    // is a fact about the implementation, so it stays out of the help text,
68    // which is reprinted under every command.
69    /// Act as this profile; overrides `YTCLI_PROFILE` and `.tracker.toml`.
70    #[arg(long, short = 'p', global = true)]
71    pub profile: Option<String>,
72
73    /// text (compact, default), json (our schema), json-raw, toon.
74    #[arg(long, short = 'f', global = true, value_name = "FORMAT")]
75    pub format: Option<Format>,
76
77    /// Print the whole description, however long.
78    #[arg(long, global = true)]
79    pub full: bool,
80
81    /// Confirm a change that touches more than one issue.
82    #[arg(long, global = true)]
83    pub yes: bool,
84
85    /// Print the request that would be sent, and send nothing.
86    #[arg(long, global = true)]
87    pub dry_run: bool,
88
89    /// Log to stderr; repeat for more. stdout stays pipeable.
90    #[arg(long, short = 'v', global = true, action = clap::ArgAction::Count)]
91    pub verbose: u8,
92
93    /// Do not draw image attachments, even where the terminal could.
94    #[arg(long, global = true)]
95    pub no_images: bool,
96
97    /// Config file to use instead of the per-user one.
98    ///
99    /// Also read from `YTCLI_CONFIG`, which is how a test or a container points
100    /// the tool at a config without rewriting every documented command line.
101    #[arg(long, global = true, env = "YTCLI_CONFIG", value_name = "PATH")]
102    pub config: Option<PathBuf>,
103}
104
105/// Top-level command groups, one per entity.
106#[derive(Debug, Subcommand)]
107pub enum Command {
108    /// Accounts, organisations and who you currently are.
109    #[command(subcommand)]
110    Auth(auth::AuthCommand),
111    /// Issues: read, search and change.
112    #[command(subcommand)]
113    Issue(issue::IssueCommand),
114    /// Queues and their fields.
115    #[command(subcommand)]
116    Queue(queue::QueueCommand),
117    /// Boards and their sprints.
118    #[command(subcommand)]
119    Board(board::BoardCommand),
120    /// Sprints, across every board.
121    #[command(subcommand)]
122    Sprint(sprint::SprintCommand),
123    /// Time logged across issues.
124    #[command(subcommand)]
125    Worklog(worklog::WorklogCommand),
126    /// People in the organisation.
127    #[command(subcommand)]
128    User(user::UserCommand),
129    /// The kinds of link two issues can have.
130    #[command(subcommand)]
131    Link(link::LinkCommand),
132    /// Bulk changes Tracker is running, or has run.
133    #[command(subcommand)]
134    Bulk(bulk::BulkCommand),
135    /// Components: the parts a queue splits its work by.
136    #[command(subcommand)]
137    Component(component::ComponentCommand),
138    /// The values issues can take: types, priorities, statuses, resolutions.
139    #[command(subcommand)]
140    Dict(dict::DictCommand),
141    /// Fields defined across the organisation.
142    #[command(subcommand)]
143    Field(field::FieldCommand),
144    /// Issue and comment templates.
145    #[command(subcommand)]
146    Template(field::TemplateCommand),
147    /// Projects.
148    #[command(subcommand)]
149    Project(project::ProjectCommand),
150    /// Portfolios: projects and portfolios grouped together.
151    #[command(subcommand)]
152    Portfolio(portfolio::PortfolioCommand),
153    /// Goals.
154    #[command(subcommand)]
155    Goal(goal::GoalCommand),
156    /// Issue attachments.
157    #[command(subcommand)]
158    Attachment(attachment::AttachmentCommand),
159    /// Print a compact reference of the whole CLI, for agents.
160    #[command(long_about = help::md(help::CHEATSHEET))]
161    Cheatsheet(cheatsheet::CheatsheetArgs),
162    /// Generate a shell completion script.
163    #[command(long_about = help::md(help::COMPLETIONS))]
164    Completions {
165        /// Shell to generate for.
166        #[arg(value_enum)]
167        shell: clap_complete::Shell,
168    },
169}
170
171/// Everything a command implementation needs, assembled once.
172#[derive(Debug)]
173pub struct Session {
174    pub config: Config,
175    /// Where `config` came from, so `auth login` can write back to it.
176    pub config_file: PathBuf,
177    pub resolved: Option<Resolved>,
178    pub render: Context,
179    pub global: GlobalArgs,
180}
181
182impl Session {
183    /// The active profile, or an auth error explaining how to get one.
184    pub fn resolved(&self) -> Result<&Resolved, crate::config::ConfigError> {
185        self.resolved
186            .as_ref()
187            .ok_or(crate::config::ConfigError::NoProfile)
188    }
189
190    /// A bare issue number completed with the default queue of its profile.
191    ///
192    /// Everything else is returned as given: this only ever fires on digits,
193    /// which no queue key can be, so nothing that already worked changes
194    /// meaning.
195    fn expanded(&self, target: &str) -> Result<String, ExitCode> {
196        let (prefix, number) = match target.split_once('/') {
197            Some((profile, key)) => (Some(profile), key),
198            None => (None, target),
199        };
200        if number.is_empty() || !number.bytes().all(|byte| byte.is_ascii_digit()) {
201            return Ok(target.to_owned());
202        }
203
204        let resolved = match prefix {
205            Some(profile) => self
206                .config
207                .resolve(Some(profile), None, std::path::Path::new("."))
208                .map_err(|error| report(&error, ExitCode::Auth))?,
209            None => self
210                .resolved()
211                .map_err(|error| report(&error, ExitCode::Auth))?
212                .clone(),
213        };
214
215        // A pinned repository's queue wins, the way it does everywhere else: the
216        // checkout says what work is being done here.
217        let Some(queue) = resolved
218            .queue
219            .as_deref()
220            .or(resolved.profile.default_queue.as_deref())
221        else {
222            return Err(report(
223                &format!(
224                    "`{number}` is a number, not an issue key, and profile {} has no default queue \
225                     to complete it with — write PROJ-{number}, or set one with `ytcli auth login`",
226                    resolved.name
227                ),
228                ExitCode::ConfirmationRequired,
229            ));
230        };
231
232        Ok(match prefix {
233            Some(profile) => format!("{profile}/{queue}-{number}"),
234            None => format!("{queue}-{number}"),
235        })
236    }
237
238    /// Split a possibly profile-qualified target and build the client for it.
239    ///
240    /// Queue keys are only unique **inside** an organisation: two profiles can
241    /// both see a `LMS`, and `LMS-12` then names two different issues. So the
242    /// key decides the profile, in this order:
243    ///
244    /// 1. `work/LMS-12` says which, and is always obeyed.
245    /// 2. Otherwise the profile that can see queue `LMS` is used, even when it
246    ///    is not the default one. Sending the request to a profile known not to
247    ///    have the queue only produces a 403 that reads like a rights problem.
248    /// 3. Two profiles in *different* organisations seeing one queue key is the
249    ///    genuinely ambiguous case, and is refused rather than guessed at.
250    /// 4. A bare number is the issue's number in the profile's default queue.
251    ///    `42` and `PROJ-42` then name the same issue, which is what somebody
252    ///    reading a board and typing a key by hand actually has in front of
253    ///    them. Without a default queue it is refused: there is nothing to
254    ///    complete it with, and a number is not a key.
255    pub async fn client_for(&self, target: &str) -> Result<(crate::api::Client, String), ExitCode> {
256        let (client, key, _) = self.routed(target).await?;
257        Ok((client, key))
258    }
259
260    /// [`Self::client_for`], and the name of the profile that answered.
261    ///
262    /// The name is what a person recognises; the organisation the client
263    /// carries is what two profiles onto the same Tracker have in common. A
264    /// command that has to remember something about "where this went" wants
265    /// both.
266    pub async fn routed(
267        &self,
268        target: &str,
269    ) -> Result<(crate::api::Client, String, String), ExitCode> {
270        let target = &self.expanded(target)?;
271        let active = || {
272            self.resolved
273                .as_ref()
274                .map_or_else(|| "default".to_owned(), |resolved| resolved.name.clone())
275        };
276        let Some((profile, key)) = target.split_once('/') else {
277            if let Some(owner) = self.owner_of(target).await? {
278                let client = self.client_with(&owner)?;
279                self.announce(&owner);
280                let name = owner.name.clone();
281                return Ok((client, target.to_owned(), name));
282            }
283            return Ok((self.client()?, target.to_owned(), active()));
284        };
285
286        // A slash with nothing useful around it is a typo, not a qualifier.
287        if profile.is_empty() || key.is_empty() {
288            return Err(report(
289                &format!("`{target}` is not a valid key; write it as PROJ-1 or profile/PROJ-1"),
290                ExitCode::ConfirmationRequired,
291            ));
292        }
293
294        let mut resolved = self
295            .config
296            .resolve(Some(profile), None, std::path::Path::new("."))
297            .map_err(|error| report(&error, ExitCode::Auth))?;
298        resolved.source = crate::config::ProfileSource::Qualified(target.to_owned());
299
300        let client = self.client_with(&resolved)?;
301        self.announce(&resolved);
302        Ok((client, key.to_owned(), resolved.name))
303    }
304
305    /// The profile that can see the queue this key belongs to.
306    ///
307    /// `None` means "no reason to leave the active profile": the key names no
308    /// queue, nothing is known about it, or the active profile is one of the
309    /// profiles that can see it.
310    async fn owner_of(&self, key: &str) -> Result<Option<Resolved>, ExitCode> {
311        // `--profile` is an instruction for this command, not a default, so it
312        // is never overridden by what a key implies. Everything else — the
313        // environment, a project pin, the configured default — is a standing
314        // choice that a key naming somebody else's queue can outvote.
315        if matches!(
316            self.resolved.as_ref().map(|resolved| &resolved.source),
317            Some(crate::config::ProfileSource::Flag)
318        ) {
319            return Ok(None);
320        }
321
322        let Some(queue) = crate::config::cache::queue_of(key) else {
323            return Ok(None);
324        };
325        if self.config.profiles.len() < 2 {
326            return Ok(None);
327        }
328
329        let mut owners = self.owners_of(queue);
330        if owners.is_empty() {
331            // Nothing known, and more than one profile to be wrong about. One
332            // request per profile, once, is cheaper than a 403 the caller has
333            // to interpret — and it is remembered afterwards.
334            self.learn_which_profile_sees_what().await;
335            owners = self.owners_of(queue);
336        }
337
338        // Same organisation through two accounts is not ambiguity: `LMS-12`
339        // means one issue, and either profile fetches it.
340        let organisations: std::collections::BTreeSet<&str> = owners
341            .iter()
342            .filter_map(|name| self.config.profiles.get(name))
343            .map(|profile| profile.org_id.as_str())
344            .collect();
345
346        if organisations.len() > 1 {
347            let qualified = owners
348                .iter()
349                .map(|profile| format!("{profile}/{key}"))
350                .collect::<Vec<_>>()
351                .join(" or ");
352            return Err(report(
353                &format!(
354                    "`{key}` is ambiguous: queue {queue} is visible in {}, in different organisations — write {qualified}",
355                    owners.join(" and "),
356                ),
357                ExitCode::ConfirmationRequired,
358            ));
359        }
360
361        let active = self
362            .resolved
363            .as_ref()
364            .map(|resolved| resolved.name.as_str());
365        if owners.is_empty() || owners.iter().any(|owner| Some(owner.as_str()) == active) {
366            return Ok(None);
367        }
368
369        let name = owners.first().cloned().unwrap_or_default();
370        let mut resolved = self
371            .config
372            .resolve(Some(&name), None, std::path::Path::new("."))
373            .map_err(|error| report(&error, ExitCode::Auth))?;
374        resolved.source = crate::config::ProfileSource::QueueOwner(queue.to_owned());
375        Ok(Some(resolved))
376    }
377
378    fn owners_of(&self, queue: &str) -> Vec<String> {
379        let configured: Vec<String> = self.config.profiles.keys().cloned().collect();
380        crate::config::cache::Cache::load(&crate::config::cache::path_for(&self.config_file))
381            .profiles_for(queue, &configured)
382    }
383
384    /// Ask every profile which queues it can see, and remember the answers.
385    ///
386    /// Best-effort throughout: a profile whose token is missing or whose
387    /// organisation refuses is skipped, because the question being answered is
388    /// "who can see this queue", and a profile that cannot answer is not it.
389    async fn learn_which_profile_sees_what(&self) {
390        let mut err = anstream::stderr();
391        let _ = writeln!(
392            err,
393            "→ asking each profile which queues it can see (once; remembered afterwards)"
394        );
395
396        let path = crate::config::cache::path_for(&self.config_file);
397        let mut cache = crate::config::cache::Cache::load(&path);
398
399        let names: Vec<String> = self.config.profiles.keys().cloned().collect();
400        for name in names {
401            let Ok(resolved) = self
402                .config
403                .resolve(Some(&name), None, std::path::Path::new("."))
404            else {
405                continue;
406            };
407            let Ok(token) = crate::secrets::token(&resolved.profile.account) else {
408                continue;
409            };
410
411            let mut config = crate::api::ClientConfig::new(
412                token,
413                resolved.profile.org_id.clone(),
414                resolved.profile.org_kind,
415            );
416            if let Ok(base) = std::env::var("YTCLI_BASE_URL") {
417                config.base_url = base;
418            }
419            let Ok(client) = crate::api::Client::new(&config) else {
420                continue;
421            };
422
423            let queues = client.queues().await.unwrap_or_default();
424            if queues.is_empty() {
425                continue;
426            }
427            let keys: Vec<String> = queues.into_iter().map(|queue| queue.key).collect();
428            cache.record(&name, &keys);
429        }
430
431        cache.save(&path);
432    }
433
434    /// Say which profile and organisation this answer came from.
435    ///
436    /// Once per run, on stderr. Every command says it, not only the writes: an
437    /// answer from the wrong organisation looks exactly like an answer from the
438    /// right one, and "which profile was that" should never be a question the
439    /// output leaves open. stderr because stdout is the data channel.
440    pub fn announce(&self, resolved: &Resolved) {
441        static SAID: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
442        if SAID.swap(true, std::sync::atomic::Ordering::Relaxed) {
443            return;
444        }
445
446        let mut err = anstream::stderr();
447        let _ = writeln!(
448            err,
449            "→ profile={} org={} (from {})",
450            resolved.name, resolved.profile.org_id, resolved.source,
451        );
452    }
453
454    /// Build an API client for the active profile.
455    ///
456    /// Every failure on the way here — no profile, no stored token, a token the
457    /// keychain will not release — is an auth problem from the caller's point of
458    /// view, and reports as one.
459    pub fn client(&self) -> Result<crate::api::Client, ExitCode> {
460        let resolved = self
461            .resolved()
462            .map_err(|error| report(&error, ExitCode::Auth))?;
463        let client = self.client_with(resolved)?;
464        self.announce(resolved);
465        Ok(client)
466    }
467
468    /// A client for a specific profile.
469    pub fn client_with(&self, resolved: &Resolved) -> Result<crate::api::Client, ExitCode> {
470        let token = crate::secrets::token(&resolved.profile.account)
471            .map_err(|error| report(&error, ExitCode::Auth))?;
472
473        let mut config = crate::api::ClientConfig::new(
474            token,
475            resolved.profile.org_id.clone(),
476            resolved.profile.org_kind,
477        );
478        // Pointing the client at a stub server is how the CLI is tested end to
479        // end; nothing else should be setting this.
480        if let Ok(base) = std::env::var("YTCLI_BASE_URL") {
481            config.base_url = base;
482        }
483
484        crate::api::Client::new(&config).map_err(|error| {
485            let code = error.exit_code();
486            report(&error, code)
487        })
488    }
489
490    /// Display defaults for the active profile, or the built-in ones.
491    #[must_use]
492    pub fn display(&self) -> crate::config::Display {
493        self.resolved
494            .as_ref()
495            .map(|r| r.profile.display.clone())
496            .unwrap_or_default()
497    }
498
499    /// The queue to act on when the command did not name one.
500    #[must_use]
501    pub fn default_queue(&self) -> Option<&str> {
502        self.resolved.as_ref().and_then(|r| r.queue.as_deref())
503    }
504}
505
506/// Print an error to stderr and hand back the exit code to return.
507pub fn report(error: &dyn std::fmt::Display, code: ExitCode) -> ExitCode {
508    let mut err = anstream::stderr();
509    let _ = writeln!(err, "error: {error}");
510    code
511}
512
513/// Write rendered output to stdout.
514pub fn emit(text: &str) {
515    let mut out = anstream::stdout();
516    let _ = write!(out, "{text}");
517}
518
519/// Build the rendering context from flags, profile defaults and the terminal.
520#[must_use]
521pub fn render_context(global: &GlobalArgs, resolved: Option<&Resolved>) -> Context {
522    let display = resolved.map(|r| &r.profile.display);
523    let audience = Audience::detect();
524
525    // Truncation exists to save an agent's context, and a person reading their
526    // own terminal has none of that problem — being handed two thirds of a
527    // description and a note about the rest is just an extra command to type.
528    // A terminal therefore gets everything unless the profile says otherwise.
529    let description_lines = if global.full {
530        None
531    } else {
532        match (audience, display) {
533            (Audience::Human, None) => None,
534            (Audience::Human, Some(display)) => display.description_lines_human,
535            (Audience::Machine, display) => Some(display.map_or(10, |d| d.description_lines)),
536        }
537    };
538
539    Context {
540        format: global
541            .format
542            .or_else(|| display.map(|d| d.format))
543            .unwrap_or_default(),
544        audience,
545        description_lines,
546        extra_fields: display.map(|d| d.extra_fields.clone()).unwrap_or_default(),
547        // The flag only ever turns images off. There is nothing to turn on: a
548        // terminal that cannot draw is not persuaded by a configuration file.
549        images: !global.no_images && display.is_none_or(|d| d.images),
550        inline: crate::render::image::Inline::default(),
551        width: match audience {
552            // Prose is wrapped to the window, within reason: a full-width
553            // paragraph on an ultrawide monitor is unreadable, and a very narrow
554            // terminal cannot be helped.
555            Audience::Human => terminal_width().clamp(40, 110),
556            // A pipe gets one width forever. Making output depend on the window
557            // it was produced in would mean two runs of the same command
558            // disagree, which is the kind of drift a fixed shape forbids.
559            Audience::Machine => 100,
560        },
561    }
562}
563
564/// The terminal width, or a sane guess.
565///
566/// A pseudo-terminal with no size set reports zero columns rather than failing,
567/// and wrapping prose to that would be worse than not asking at all — anything
568/// implausibly narrow is treated as "unknown", not as the answer.
569pub(crate) fn terminal_width() -> usize {
570    const UNKNOWN: usize = 100;
571    match termimad::crossterm::terminal::size() {
572        Ok((cols, _)) if cols >= 20 => cols as usize,
573        _ => UNKNOWN,
574    }
575}
576
577/// Placeholder for a command that is declared but not built yet.
578///
579/// It exists so the command tree, its help text and the shell completions are
580/// real from the first commit; the implementations land behind them.
581#[must_use]
582pub fn not_implemented(what: &str) -> ExitCode {
583    let mut err = anstream::stderr();
584    let _ = writeln!(
585        err,
586        "`{what}` is not implemented in this build yet — see docs/TODO.md"
587    );
588    ExitCode::NotImplemented
589}