yandex-tracker-cli 1.0.1

Token-efficient Yandex Tracker CLI for humans and AI agents
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
//! The command shell.
//!
//! The verb is the risk class. Read verbs (`get`, `find`, `list`, `status`) can
//! never write, and there is no generic pass-through that would let one smuggle a
//! change past that rule. That is what makes a permission allowlist like
//! `ytcli issue get:*` meaningful for an agent host (`docs/adr/0001-security-model.md`).

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};

/// Token-efficient Yandex Tracker CLI for humans and AI agents.
#[derive(Debug, Parser)]
#[command(name = "ytcli", version, about, long_about = help::md(help::ROOT))]
// On the long help only, so `-h` stays the size it is: somebody who asked for
// the short form asked for less, not for less of a different thing.
#[command(after_long_help = help::LINKS)]
#[command(propagate_version = true)]
// Help is rendered markdown, and clap's own wrapping counts escape codes as
// characters — it would cut a table in half and break an example mid-flag.
// The text arrives already wrapped to the window.
#[command(term_width = 0)]
pub struct Cli {
    #[command(subcommand)]
    pub command: Command,

    #[command(flatten)]
    pub global: GlobalArgs,
}

/// Flags every command accepts.
#[derive(Debug, Args, Clone)]
// A command-line flag is a bool, and clap needs one field per flag. A state
// machine here would be a fiction maintained for a lint.
#[allow(clippy::struct_excessive_bools)]
pub struct GlobalArgs {
    // `YTCLI_PROFILE` is read separately rather than through clap's `env`, so
    // that `auth status` can report which of the two the value came from. That
    // is a fact about the implementation, so it stays out of the help text,
    // which is reprinted under every command.
    /// Act as this profile; overrides `YTCLI_PROFILE` and `.tracker.toml`.
    #[arg(long, short = 'p', global = true)]
    pub profile: Option<String>,

    /// text (compact, default), json (our schema), json-raw, toon.
    #[arg(long, short = 'f', global = true, value_name = "FORMAT")]
    pub format: Option<Format>,

    /// Print the whole description, however long.
    #[arg(long, global = true)]
    pub full: bool,

    /// Confirm a change that touches more than one issue.
    #[arg(long, global = true)]
    pub yes: bool,

    /// Print the request that would be sent, and send nothing.
    #[arg(long, global = true)]
    pub dry_run: bool,

    /// Log to stderr; repeat for more. stdout stays pipeable.
    #[arg(long, short = 'v', global = true, action = clap::ArgAction::Count)]
    pub verbose: u8,

    /// Do not draw image attachments, even where the terminal could.
    #[arg(long, global = true)]
    pub no_images: bool,

    /// Config file to use instead of the per-user one.
    ///
    /// Also read from `YTCLI_CONFIG`, which is how a test or a container points
    /// the tool at a config without rewriting every documented command line.
    #[arg(long, global = true, env = "YTCLI_CONFIG", value_name = "PATH")]
    pub config: Option<PathBuf>,
}

/// Top-level command groups, one per entity.
#[derive(Debug, Subcommand)]
pub enum Command {
    /// Accounts, organisations and who you currently are.
    #[command(subcommand)]
    Auth(auth::AuthCommand),
    /// Issues: read, search and change.
    #[command(subcommand)]
    Issue(issue::IssueCommand),
    /// Queues and their fields.
    #[command(subcommand)]
    Queue(queue::QueueCommand),
    /// Boards and their sprints.
    #[command(subcommand)]
    Board(board::BoardCommand),
    /// Sprints, across every board.
    #[command(subcommand)]
    Sprint(sprint::SprintCommand),
    /// Time logged across issues.
    #[command(subcommand)]
    Worklog(worklog::WorklogCommand),
    /// People in the organisation.
    #[command(subcommand)]
    User(user::UserCommand),
    /// The kinds of link two issues can have.
    #[command(subcommand)]
    Link(link::LinkCommand),
    /// Bulk changes Tracker is running, or has run.
    #[command(subcommand)]
    Bulk(bulk::BulkCommand),
    /// Components: the parts a queue splits its work by.
    #[command(subcommand)]
    Component(component::ComponentCommand),
    /// The values issues can take: types, priorities, statuses, resolutions.
    #[command(subcommand)]
    Dict(dict::DictCommand),
    /// Fields defined across the organisation.
    #[command(subcommand)]
    Field(field::FieldCommand),
    /// Issue and comment templates.
    #[command(subcommand)]
    Template(field::TemplateCommand),
    /// Projects.
    #[command(subcommand)]
    Project(project::ProjectCommand),
    /// Portfolios: projects and portfolios grouped together.
    #[command(subcommand)]
    Portfolio(portfolio::PortfolioCommand),
    /// Goals.
    #[command(subcommand)]
    Goal(goal::GoalCommand),
    /// Issue attachments.
    #[command(subcommand)]
    Attachment(attachment::AttachmentCommand),
    /// Print a compact reference of the whole CLI, for agents.
    #[command(long_about = help::md(help::CHEATSHEET))]
    Cheatsheet(cheatsheet::CheatsheetArgs),
    /// Generate a shell completion script.
    #[command(long_about = help::md(help::COMPLETIONS))]
    Completions {
        /// Shell to generate for.
        #[arg(value_enum)]
        shell: clap_complete::Shell,
    },
}

/// Everything a command implementation needs, assembled once.
#[derive(Debug)]
pub struct Session {
    pub config: Config,
    /// Where `config` came from, so `auth login` can write back to it.
    pub config_file: PathBuf,
    pub resolved: Option<Resolved>,
    pub render: Context,
    pub global: GlobalArgs,
}

impl Session {
    /// The active profile, or an auth error explaining how to get one.
    pub fn resolved(&self) -> Result<&Resolved, crate::config::ConfigError> {
        self.resolved
            .as_ref()
            .ok_or(crate::config::ConfigError::NoProfile)
    }

    /// A bare issue number completed with the default queue of its profile.
    ///
    /// Everything else is returned as given: this only ever fires on digits,
    /// which no queue key can be, so nothing that already worked changes
    /// meaning.
    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(),
        };

        // A pinned repository's queue wins, the way it does everywhere else: the
        // checkout says what work is being done here.
        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}"),
        })
    }

    /// Split a possibly profile-qualified target and build the client for it.
    ///
    /// Queue keys are only unique **inside** an organisation: two profiles can
    /// both see a `LMS`, and `LMS-12` then names two different issues. So the
    /// key decides the profile, in this order:
    ///
    /// 1. `work/LMS-12` says which, and is always obeyed.
    /// 2. Otherwise the profile that can see queue `LMS` is used, even when it
    ///    is not the default one. Sending the request to a profile known not to
    ///    have the queue only produces a 403 that reads like a rights problem.
    /// 3. Two profiles in *different* organisations seeing one queue key is the
    ///    genuinely ambiguous case, and is refused rather than guessed at.
    /// 4. A bare number is the issue's number in the profile's default queue.
    ///    `42` and `PROJ-42` then name the same issue, which is what somebody
    ///    reading a board and typing a key by hand actually has in front of
    ///    them. Without a default queue it is refused: there is nothing to
    ///    complete it with, and a number is not a key.
    pub async fn client_for(&self, target: &str) -> Result<(crate::api::Client, String), ExitCode> {
        let (client, key, _) = self.routed(target).await?;
        Ok((client, key))
    }

    /// [`Self::client_for`], and the name of the profile that answered.
    ///
    /// The name is what a person recognises; the organisation the client
    /// carries is what two profiles onto the same Tracker have in common. A
    /// command that has to remember something about "where this went" wants
    /// both.
    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()));
        };

        // A slash with nothing useful around it is a typo, not a qualifier.
        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))
    }

    /// The profile that can see the queue this key belongs to.
    ///
    /// `None` means "no reason to leave the active profile": the key names no
    /// queue, nothing is known about it, or the active profile is one of the
    /// profiles that can see it.
    async fn owner_of(&self, key: &str) -> Result<Option<Resolved>, ExitCode> {
        // `--profile` is an instruction for this command, not a default, so it
        // is never overridden by what a key implies. Everything else — the
        // environment, a project pin, the configured default — is a standing
        // choice that a key naming somebody else's queue can outvote.
        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() {
            // Nothing known, and more than one profile to be wrong about. One
            // request per profile, once, is cheaper than a 403 the caller has
            // to interpret — and it is remembered afterwards.
            self.learn_which_profile_sees_what().await;
            owners = self.owners_of(queue);
        }

        // Same organisation through two accounts is not ambiguity: `LMS-12`
        // means one issue, and either profile fetches it.
        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)
    }

    /// Ask every profile which queues it can see, and remember the answers.
    ///
    /// Best-effort throughout: a profile whose token is missing or whose
    /// organisation refuses is skipped, because the question being answered is
    /// "who can see this queue", and a profile that cannot answer is not it.
    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);
    }

    /// Say which profile and organisation this answer came from.
    ///
    /// Once per run, on stderr. Every command says it, not only the writes: an
    /// answer from the wrong organisation looks exactly like an answer from the
    /// right one, and "which profile was that" should never be a question the
    /// output leaves open. stderr because stdout is the data channel.
    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,
        );
    }

    /// Build an API client for the active profile.
    ///
    /// Every failure on the way here — no profile, no stored token, a token the
    /// keychain will not release — is an auth problem from the caller's point of
    /// view, and reports as one.
    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)
    }

    /// A client for a specific profile.
    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,
        );
        // Pointing the client at a stub server is how the CLI is tested end to
        // end; nothing else should be setting this.
        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)
        })
    }

    /// Display defaults for the active profile, or the built-in ones.
    #[must_use]
    pub fn display(&self) -> crate::config::Display {
        self.resolved
            .as_ref()
            .map(|r| r.profile.display.clone())
            .unwrap_or_default()
    }

    /// The queue to act on when the command did not name one.
    #[must_use]
    pub fn default_queue(&self) -> Option<&str> {
        self.resolved.as_ref().and_then(|r| r.queue.as_deref())
    }
}

/// Print an error to stderr and hand back the exit code to return.
pub fn report(error: &dyn std::fmt::Display, code: ExitCode) -> ExitCode {
    let mut err = anstream::stderr();
    let _ = writeln!(err, "error: {error}");
    code
}

/// Write rendered output to stdout.
pub fn emit(text: &str) {
    let mut out = anstream::stdout();
    let _ = write!(out, "{text}");
}

/// Build the rendering context from flags, profile defaults and the terminal.
#[must_use]
pub fn render_context(global: &GlobalArgs, resolved: Option<&Resolved>) -> Context {
    let display = resolved.map(|r| &r.profile.display);
    let audience = Audience::detect();

    // Truncation exists to save an agent's context, and a person reading their
    // own terminal has none of that problem — being handed two thirds of a
    // description and a note about the rest is just an extra command to type.
    // A terminal therefore gets everything unless the profile says otherwise.
    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(),
        // The flag only ever turns images off. There is nothing to turn on: a
        // terminal that cannot draw is not persuaded by a configuration file.
        images: !global.no_images && display.is_none_or(|d| d.images),
        inline: crate::render::image::Inline::default(),
        width: match audience {
            // Prose is wrapped to the window, within reason: a full-width
            // paragraph on an ultrawide monitor is unreadable, and a very narrow
            // terminal cannot be helped.
            Audience::Human => terminal_width().clamp(40, 110),
            // A pipe gets one width forever. Making output depend on the window
            // it was produced in would mean two runs of the same command
            // disagree, which is the kind of drift a fixed shape forbids.
            Audience::Machine => 100,
        },
    }
}

/// The terminal width, or a sane guess.
///
/// A pseudo-terminal with no size set reports zero columns rather than failing,
/// and wrapping prose to that would be worse than not asking at all — anything
/// implausibly narrow is treated as "unknown", not as the answer.
pub(crate) fn terminal_width() -> usize {
    const UNKNOWN: usize = 100;
    match termimad::crossterm::terminal::size() {
        Ok((cols, _)) if cols >= 20 => cols as usize,
        _ => UNKNOWN,
    }
}

/// Placeholder for a command that is declared but not built yet.
///
/// It exists so the command tree, its help text and the shell completions are
/// real from the first commit; the implementations land behind them.
#[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
}