Skip to main content

tix/
cli.rs

1//! The clap command tree, the frozen exit-code scheme, and the process
2//! boundary that resolves config, opens the store and hands off to the
3//! per-command handlers.
4//!
5//! The command tree and every long flag mirror `contracts/cli.md` exactly;
6//! the exit-code table there is frozen: `0` success, `1` usage/config/
7//! unknown-error, `2` target-not-found or selection-ambiguous, `3` external
8//! tool missing (`glab`/`linear-cli`, reserved for WP05). Handlers return
9//! [`Result<(), TixError>`]; [`exit_code_for`] maps the typed error surface
10//! to the exit-code enum at the boundary. Human-readable errors go to
11//! stderr; stdout stays clean for the parseability contract.
12
13use std::path::{Path, PathBuf};
14
15use clap::builder::NonEmptyStringValueParser;
16use clap::{Args, Parser, Subcommand};
17
18use crate::commands;
19use crate::config::Config;
20use crate::error::{Result, TixError};
21use crate::storage::Store;
22
23/// Exit codes frozen by `contracts/cli.md`.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum ExitCode {
26    Success = 0,
27    UsageConfig = 1,
28    NotFoundAmbiguous = 2,
29    ExternalTool = 3,
30}
31
32/// Map a domain error to the frozen exit-code table.
33///
34/// Selection ambiguity shares exit `2` with target-not-found, per the
35/// contract's exit-code table ("2 target-not-found or selection-ambiguous");
36/// the ambiguity marker is the [`commands::AMBIGUITY_PREFIX`] message prefix.
37pub fn exit_code_for(err: &TixError) -> ExitCode {
38    match err {
39        TixError::NotFound(_) => ExitCode::NotFoundAmbiguous,
40        TixError::InvalidInput(s) if s.starts_with(commands::AMBIGUITY_PREFIX) => {
41            ExitCode::NotFoundAmbiguous
42        }
43        TixError::External(_) => ExitCode::ExternalTool,
44        _ => ExitCode::UsageConfig,
45    }
46}
47
48#[derive(Parser)]
49#[command(
50    name = "tix",
51    version,
52    about = "Terminal issue tracker: markdown issues with a kanban TUI and CLI"
53)]
54pub struct Cli {
55    #[command(subcommand)]
56    pub command: Option<Command>,
57}
58
59#[derive(Subcommand)]
60pub enum Command {
61    /// Create a default tix.toml ([kanban.default], issues_dir, [export])
62    Init,
63    /// Append or replace the tix block in AGENTS.md (idempotent)
64    InitAgents,
65    /// Create an issue
66    Create {
67        /// Issue title (prompted when missing on a TTY)
68        #[arg(long)]
69        title: Option<String>,
70        /// Issue body (markdown)
71        #[arg(long)]
72        body: Option<String>,
73        /// Column name (default: first column)
74        #[arg(long)]
75        status: Option<String>,
76        /// Priority: low | medium | high (default medium)
77        #[arg(long)]
78        priority: Option<String>,
79        /// Assignee (@name)
80        #[arg(long)]
81        assignee: Option<String>,
82        /// Labels, comma-separated
83        #[arg(long)]
84        labels: Option<String>,
85        /// Files governing this issue, comma-separated
86        #[arg(long)]
87        files: Option<String>,
88        /// Estimate
89        #[arg(long)]
90        estimate: Option<String>,
91        /// Cycle names, comma-separated
92        #[arg(long)]
93        cycles: Option<String>,
94        /// Milestone names, comma-separated
95        #[arg(long)]
96        milestones: Option<String>,
97        /// Reviewer (free-form name/identity)
98        #[arg(long)]
99        reviewer: Option<String>,
100        /// Start date (`YYYY-MM-DD`)
101        #[arg(long)]
102        start_date: Option<String>,
103        /// Due date (`YYYY-MM-DD`); must be `>= start_date`
104        #[arg(long)]
105        due_date: Option<String>,
106        /// Create as a child of this issue (number, filename or title fragment)
107        ///
108        /// At most one parent, so this takes a single selector and there is no list
109        /// form (C-001). The parent must already exist; a selector that matches no
110        /// issue is refused and nothing is created.
111        #[arg(long, value_name = "SELECTOR")]
112        parent: Option<String>,
113        /// Related issues, comma-separated: a local number/selector, a relative
114        /// or absolute path to another issue file, or a repository URL
115        /// (`host/path#id`). Free-form; stored verbatim.
116        #[arg(long)]
117        related_issues: Option<String>,
118        /// Issue type (e.g. `bug`, `feature`); `tix lint` warns on a value not in
119        /// `custom_types`.
120        #[arg(long)]
121        r#type: Option<String>,
122    },
123    /// Create, edit, remove, or list typed relationships
124    ///
125    /// Creating requires `--type`; there is no default kind, so an untyped
126    /// relationship is never recorded by accident (C-001). Omit the
127    /// subcommand to create: `tix link <from> <to> --type <kind>`.
128    Link {
129        /// `edit`, `remove`, or `list`. Omit to create a relationship.
130        ///
131        /// These three words are reserved on `tix link`: an issue whose number,
132        /// filename, or title selector is exactly `edit`, `remove`, or `list`
133        /// must be addressed by number.
134        #[command(subcommand)]
135        action: Option<LinkAction>,
136        /// [create] Issue number, filename fragment or unique title fragment
137        from: Option<String>,
138        /// [create] Issue number, filename fragment or unique title fragment
139        to: Option<String>,
140        /// [create] Relationship kind: `blocks`, `blocked_by`, `duplicates`, `related`
141        #[arg(long, value_name = "KIND")]
142        r#type: Option<String>,
143    },
144    /// Batch editing over a selection
145    ///
146    /// Preview is the **default**; `--apply` writes. Name exactly one of `--select`,
147    /// `--filter` or `--search` — they are different grammars, and a bulk operation
148    /// will not guess which issues you meant.
149    Bulk {
150        #[command(subcommand)]
151        action: BulkAction,
152    },
153    /// Remove a typed relationship between two issues
154    Unlink {
155        /// Issue number, filename fragment or unique title fragment
156        from: String,
157        /// Issue number, filename fragment or unique title fragment
158        to: String,
159        /// Relationship kind: `blocks`, `blocked_by`, `duplicates`, `related`
160        #[arg(long, value_name = "KIND")]
161        r#type: Option<String>,
162    },
163    /// Print the transitive dependency chain an issue holds up
164    Chain {
165        /// Issue number, filename fragment or unique title fragment
166        issue: String,
167    },
168    /// Inspect the issue hierarchy
169    ///
170    /// `show`, `children`, or `list`. Every figure comes from the hierarchy
171    /// service's typed values, so the CLI, the TUI detail block and the MCP read
172    /// tools report the same numbers for the same issue.
173    Parent {
174        /// `show`, `children`, or `list`
175        #[command(subcommand)]
176        action: ParentAction,
177    },
178    /// Edit an issue
179    Edit {
180        /// Issue number, filename fragment or unique title fragment
181        issue: String,
182        /// New title
183        #[arg(long)]
184        title: Option<String>,
185        /// New body (markdown)
186        #[arg(long)]
187        body: Option<String>,
188        /// New status
189        ///
190        /// Setting the status is a **move**, validated by the same service as
191        /// `tix mv` and recorded as the same `TransitionDecision` — it is not a
192        /// way around the transition policy. `--status ""` clears the field
193        /// (matching every other field here) and is a plain field write, not a
194        /// transition, because there is no target column to judge.
195        #[arg(long)]
196        status: Option<String>,
197        /// New priority: low | medium | high
198        #[arg(long)]
199        priority: Option<String>,
200        /// New assignee (@name)
201        #[arg(long)]
202        assignee: Option<String>,
203        /// New label list, comma-separated
204        #[arg(long)]
205        labels: Option<String>,
206        /// New files list, comma-separated
207        #[arg(long)]
208        files: Option<String>,
209        /// New estimate
210        #[arg(long)]
211        estimate: Option<String>,
212        /// New cycle list, comma-separated (`--cycles ""` clears)
213        #[arg(long)]
214        cycles: Option<String>,
215        /// New milestone list, comma-separated (`--milestones ""` clears)
216        #[arg(long)]
217        milestones: Option<String>,
218        /// New reviewer (`--reviewer ""` clears)
219        #[arg(long)]
220        reviewer: Option<String>,
221        /// New start date (`YYYY-MM-DD`; `--start-date ""` clears)
222        #[arg(long)]
223        start_date: Option<String>,
224        /// New due date (`YYYY-MM-DD`; `--due-date ""` clears)
225        #[arg(long)]
226        due_date: Option<String>,
227        /// Commit hash to record (`auto` resolves it from git history, `clear` removes it)
228        #[arg(long)]
229        commit: Option<String>,
230        /// Set or change the parent (number, filename or title fragment)
231        ///
232        /// `--parent ""` clears the parent, mirroring `--assignee ""`; omitting the
233        /// flag leaves it untouched. Validated before any write, so a self-parent,
234        /// an unknown parent, or a change that would close a cycle leaves the file
235        /// byte-identical.
236        #[arg(long, value_name = "SELECTOR")]
237        parent: Option<String>,
238        /// Why a transition policy is being overridden by `--status`
239        ///
240        /// `--status` is validated exactly as `tix mv` is, so moving an issue out
241        /// of a terminal category needs the same reasoned override, and the reason
242        /// is recorded in the same History decision. `--status` deliberately has
243        /// no `--yes`: acknowledging blockers is spelled `tix move … --yes`, and
244        /// giving this flag a second spelling would give the audit trail two.
245        #[arg(long, value_name = "TEXT")]
246        reason: Option<String>,
247        /// New related-issues list, comma-separated (`--related-issues ""` clears).
248        /// A local number/selector, a relative or absolute path to another issue
249        /// file, or a repository URL (`host/path#id`).
250        #[arg(long)]
251        related_issues: Option<String>,
252        /// New issue type (`--type ""` clears it); `tix lint` warns on a value not
253        /// in `custom_types`.
254        #[arg(long)]
255        r#type: Option<String>,
256    },
257    /// Move an issue to a column
258    Move {
259        /// Issue number, filename fragment or unique title fragment
260        issue: String,
261        /// Target column
262        column: String,
263        /// Commit hash to record when moving to a completion column (`auto`
264        /// resolves it from git history; omitted, the current HEAD is captured
265        /// automatically)
266        ///
267        /// "Completion" is whatever the board's category mapping calls
268        /// `completed` — not a column literally named `done`. On a board with
269        /// no `[kanban.<name>.category_map]` the board's `done_column` is the
270        /// completion column.
271        #[arg(long, value_name = "HASH")]
272        commit: Option<String>,
273        /// Why a transition policy is being overridden
274        ///
275        /// Required to move an issue *out of* a terminal category (`completed`
276        /// or `canceled`) when the board declares a `category_map`. The reason
277        /// is recorded in the issue's History, so the audit trail explains
278        /// itself later. A blank value is not a reason and is refused.
279        ///
280        /// A board with no `category_map` has no transition policy to override:
281        /// it keeps the completion definition and the commit rule, and moving
282        /// out of `done` stays permitted.
283        #[arg(long, value_name = "TEXT")]
284        reason: Option<String>,
285        /// Confirm without prompting
286        ///
287        /// Also the acknowledgement a move toward the board's completion column
288        /// needs when the issue has open blockers. Without it that move is
289        /// refused, and a recorded acknowledgement is appended to the issue's
290        /// History so the decision stays auditable.
291        #[arg(long)]
292        yes: bool,
293    },
294    /// Print the full issue (frontmatter + body) to stdout
295    #[command(visible_alias = "show")]
296    View {
297        /// Issue number, filename fragment or unique title fragment
298        issue: String,
299        /// Extra information to append, comma-separated
300        ///
301        /// `relationships` appends the service-derived relationship block, the
302        /// transitive chain, and the blocked reason. Without it the issue file
303        /// is printed verbatim, so `tix view <n> > file` still round-trips.
304        #[arg(long, value_name = "relationships")]
305        show: Option<String>,
306    },
307    /// Table of issues
308    List {
309        /// Only issues in this status
310        #[arg(long)]
311        status: Option<String>,
312        /// Token query (`cycle:`, `milestone:`, `reviewer:`, `overdue`, etc.)
313        #[arg(long)]
314        filter: Option<String>,
315        /// Include archived issues, marked with an ARCHIVED column
316        ///
317        /// Off by default, and off for a reason: archived issues are skipped
318        /// without being read, so a repository with thousands of them lists fast.
319        #[arg(long)]
320        with_archived: bool,
321        /// Sort key (`created`, `due`, `start`, `reviewer`, `cycle`, `milestone`, `status`, `priority`)
322        #[arg(long)]
323        sort: Option<String>,
324        /// Group key (`cycle` | `milestone`)
325        #[arg(long)]
326        group: Option<String>,
327        /// Comma-separated columns to display
328        #[arg(
329            long,
330            value_name = "num,title,status,priority,cycle,milestone,reviewer,start,due,overdue"
331        )]
332        columns: Option<String>,
333        /// Extra information to include, comma-separated
334        ///
335        /// `relationships` adds a relationship summary and a blocked marker
336        /// per issue, read from the relationship service so the CLI and the
337        /// TUI cannot disagree.
338        ///
339        /// `progress` adds a parent rollup; `category` adds the issue's resolved
340        /// category label, read from the shared service so this surface and the
341        /// TUI cannot spell one category two ways.
342        #[arg(long, value_name = "SHOW")]
343        show: Option<String>,
344    },
345    /// Inspect the status-category vocabulary, a board's mapping, and the
346    /// effective transition policy
347    ///
348    /// A read-only surface. It never writes and never repairs, so it is safe to run
349    /// on a repository whose `tix.toml` is mid-edit.
350    Workflow {
351        #[command(subcommand)]
352        action: WorkflowAction,
353    },
354    /// Board summary: count per column + total
355    Status {
356        /// Token query (`cycle:`, `milestone:`, `reviewer:`, `overdue`, etc.)
357        #[arg(long)]
358        filter: Option<String>,
359        /// Sort key (`created`, `due`, `start`, `reviewer`, `cycle`, `milestone`, `status`, `priority`)
360        #[arg(long)]
361        sort: Option<String>,
362        /// Group key (`cycle` | `milestone`)
363        #[arg(long)]
364        group: Option<String>,
365    },
366    /// Triage the inbox: read-only queue views and the four decisions
367    Triage {
368        #[command(subcommand)]
369        action: TriageAction,
370    },
371    /// Delete an issue file
372    Delete {
373        /// Issue number, filename fragment or unique title fragment
374        issue: String,
375        /// Confirm without prompting
376        #[arg(long)]
377        yes: bool,
378    },
379    /// Archive issue files: rename each in place to a dot-prefixed name
380    ///
381    /// The default views (`tix list`, `tix search`) skip archived issues; pass
382    /// their `--with-archived` flag or the `archived:true` filter to see them.
383    Archive {
384        /// Issue numbers, filename fragments or unique title fragments
385        #[arg(required = true, value_name = "ISSUE")]
386        issues: Vec<String>,
387    },
388    /// Restore archived issue files: the inverse of `tix archive`
389    Restore {
390        /// Issue numbers, filename fragments or unique title fragments
391        #[arg(required = true, value_name = "ISSUE")]
392        issues: Vec<String>,
393    },
394    /// Manage planning cycles
395    Cycle {
396        #[command(subcommand)]
397        command: CycleCommand,
398    },
399    /// Manage milestones
400    Milestone {
401        #[command(subcommand)]
402        command: MilestoneCommand,
403    },
404    /// Export local issues to an external tracker via glab/linear-cli
405    Export {
406        /// Target tracker: gitlab | linear
407        #[arg(long, value_name = "gitlab|linear|ics")]
408        target: String,
409        /// Print what would change without invoking the remote tracker
410        #[arg(long)]
411        dry_run: bool,
412        /// Override the configured project path (gitlab) or team (linear)
413        #[arg(long)]
414        project: Option<String>,
415        /// Cycle to export for ICS output
416        #[arg(long)]
417        cycle: Option<String>,
418        /// Output path for ICS export
419        #[arg(long)]
420        output: Option<String>,
421    },
422    /// Import open issues from an external tracker via glab/linear-cli, or
423    /// mirror a local GitHub Spec Kit project
424    Import {
425        /// Target: gitlab | linear (remote trackers) | speckit (local mirror)
426        #[arg(long, value_name = "gitlab|linear|speckit")]
427        target: String,
428        /// Where an imported issue lands in the local triage inbox (WP07)
429        ///
430        /// `entry` routes into `[triage].entry_status` and refuses a project with
431        /// no `[triage]` section instead of quietly falling back; `remote` keeps
432        /// the column the remote mapping named. Absent is the documented default:
433        /// route when the project has an inbox, otherwise leave the mapping alone.
434        /// Either way the report names the mapping that was used.
435        #[arg(long, value_name = "entry|remote")]
436        triage_policy: Option<String>,
437    },
438    /// Roll open issues forward from expired cycles to the next successor
439    Rollover {
440        /// Restrict rollover to one source cycle
441        #[arg(long)]
442        cycle: Option<String>,
443        /// Skip interactive confirmation
444        #[arg(long)]
445        yes: bool,
446    },
447    /// Search issue files for a string (case-insensitive substring over full markdown)
448    Search {
449        /// Case-insensitive substring to match against the full markdown of every issue
450        #[arg(value_name = "STRING", value_parser = NonEmptyStringValueParser::new())]
451        string: String,
452        /// Include archived issues in the search corpus
453        #[arg(long)]
454        with_archived: bool,
455    },
456    /// Render the current board once and exit
457    Render,
458    /// Remove the backup files `tix` leaves beside issues it rewrites
459    ///
460    /// `*.tixbak` (an editor save, a repair, a conflict) and `*.md.bak` (an
461    /// import overwrite). By default each file is confirmed interactively.
462    Clean {
463        /// Remove every backup without prompting
464        #[arg(short, long)]
465        force: bool,
466    },
467    /// Open the TUI with the create-issue form already open
468    ///
469    /// The session `tix` starts, with the modal that `n` opens already in front of
470    /// you: one command to file an issue, instead of "start the board, then press
471    /// n". It is the interactive twin of `tix create`, which never opens the TUI.
472    New,
473    /// Validate frontmatter option values in all issue files
474    Lint {
475        /// Do not lint the foreign repositories reachable from `related_issues`
476        /// for this run (overrides `lint_foreign_repositories` in tix.toml).
477        #[arg(long)]
478        no_foreign: bool,
479    },
480    /// Inspect or migrate the configured on-disk issue layout (`[layout]`)
481    Layout {
482        #[command(subcommand)]
483        action: LayoutAction,
484    },
485    /// Serve the MCP protocol (JSON-RPC 2.0 over stdio) for agent tools
486    Mcp,
487}
488
489/// The `tix triage` tree (`contracts/cli-surface.md`, WP05 T023–T025).
490///
491/// `list` and `status` are read-only queue views; the four decision verbs take a
492/// single issue; `batch` is the selection-shaped twin of `tix bulk`, preview by
493/// default and writing only under an explicit `--apply`.
494///
495/// ## Why the reason flag is `Option` on `accept` and the rest
496///
497/// `contracts/decision-history.md` splits it: decline, duplicate and defer
498/// *require* a reason, accept records one when supplied. A required clap argument
499/// on three of the four would make the fourth's absence look like an oversight,
500/// and — more importantly — the enforcement belongs to the service, which refuses
501/// `missing_reason` before any write on every surface (NFR-003). The flag is
502/// therefore optional everywhere and the *rule* is the service's, so the CLI,
503/// TUI and MCP cannot end up with three different ideas of which decision needs
504/// one. `tix triage decline 1` with no `--reason` still fails, at the service,
505/// with the contract's own kind.
506#[derive(Subcommand)]
507pub enum TriageAction {
508    /// Read the inbox queue: one row per entry (FR-001)
509    List {
510        /// Sort by entry age, oldest first; an unknown age sorts last
511        #[arg(long)]
512        age: bool,
513        /// Print the counts only, with no entries
514        #[arg(long)]
515        count: bool,
516    },
517    /// Read the inbox summary: decision counts and the age histogram (FR-005)
518    Status,
519    /// Accept: move into the normal workflow, validated by #183's transition policy
520    Accept {
521        /// Issue number, filename fragment or unique title fragment
522        issue: String,
523        /// Reason recorded in History; accept records one when supplied
524        #[arg(long)]
525        reason: Option<String>,
526        /// Target column; defaults to `[triage].accepted_status`
527        #[arg(long, value_name = "COLUMN")]
528        to: Option<String>,
529        /// Who decided; defaults to the git identity
530        #[arg(long)]
531        actor: Option<String>,
532    },
533    /// Decline: move to `[triage].declined_status`; never deletes or archives
534    Decline {
535        /// Issue number, filename fragment or unique title fragment
536        issue: String,
537        /// Why it is declined (required)
538        #[arg(long)]
539        reason: Option<String>,
540        /// Who decided; defaults to the git identity
541        #[arg(long)]
542        actor: Option<String>,
543    },
544    /// Duplicate: record a typed `duplicates` relation; never merges files
545    Duplicate {
546        /// Issue number, filename fragment or unique title fragment
547        issue: String,
548        /// The issue this one duplicates
549        #[arg(long, value_name = "ISSUE")]
550        of: String,
551        /// Why it is a duplicate (required)
552        #[arg(long)]
553        reason: Option<String>,
554        /// Who decided; defaults to the git identity
555        #[arg(long)]
556        actor: Option<String>,
557    },
558    /// Defer: stay in the inbox with a refreshed entry time
559    Defer {
560        /// Issue number, filename fragment or unique title fragment
561        issue: String,
562        /// Why it is deferred (required)
563        #[arg(long)]
564        reason: Option<String>,
565        /// Who decided; defaults to the git identity
566        #[arg(long)]
567        actor: Option<String>,
568    },
569    /// One decision across a selection: preview by default, `--apply` to write
570    Batch {
571        /// Comma-separated selectors: number, filename or title fragment
572        #[arg(long, value_name = "LIST")]
573        select: Option<String>,
574        /// A `tix list --filter` query selecting the issues
575        #[arg(long, value_name = "QUERY")]
576        filter: Option<String>,
577        /// Which decision to make: accept | decline | duplicate | defer
578        #[arg(long, value_name = "KIND")]
579        decision: String,
580        /// Reason recorded on every applied event; required for three of four
581        #[arg(long)]
582        reason: Option<String>,
583        /// Who decided; defaults to the git identity
584        #[arg(long)]
585        actor: Option<String>,
586        /// The duplicate target selector, for `--decision duplicate`
587        #[arg(long, value_name = "ISSUE")]
588        of: Option<String>,
589        /// The accept target column, for `--decision accept`
590        #[arg(long, value_name = "COLUMN")]
591        to: Option<String>,
592        /// Apply the previewed plan; without it nothing is written
593        #[arg(long)]
594        apply: bool,
595    },
596}
597
598#[derive(Subcommand)]
599pub enum CycleCommand {
600    Create {
601        #[arg(long)]
602        name: Option<String>,
603        #[arg(long)]
604        description: Option<String>,
605        #[arg(long = "start-date")]
606        start_date: Option<String>,
607        #[arg(long = "due-date")]
608        due_date: Option<String>,
609    },
610    List,
611    Edit {
612        #[arg(long)]
613        name: String,
614        #[arg(long = "new-name")]
615        new_name: Option<String>,
616        #[arg(long)]
617        description: Option<String>,
618        #[arg(long = "start-date")]
619        start_date: Option<String>,
620        #[arg(long = "due-date")]
621        due_date: Option<String>,
622    },
623    Delete {
624        #[arg(long)]
625        name: String,
626        #[arg(long)]
627        yes: bool,
628    },
629}
630
631#[derive(Subcommand)]
632pub enum MilestoneCommand {
633    Create {
634        #[arg(long)]
635        name: String,
636        #[arg(long = "target-date")]
637        target_date: Option<String>,
638        #[arg(long)]
639        description: Option<String>,
640    },
641    List,
642    Edit {
643        #[arg(long)]
644        name: String,
645        #[arg(long = "new-name")]
646        new_name: Option<String>,
647        #[arg(long = "target-date")]
648        target_date: Option<String>,
649        #[arg(long)]
650        description: Option<String>,
651    },
652    Delete {
653        #[arg(long)]
654        name: String,
655        #[arg(long)]
656        yes: bool,
657    },
658}
659
660/// The `tix parent` subcommands.
661///
662/// A **required** subcommand, unlike `tix link`'s three optional verbs. `tix link`
663/// has a meaningful bare form (create); `tix parent` with no argument is a user
664/// mistake, and guessing which of three reports they wanted is worse than asking.
665#[derive(Debug, Clone, Subcommand)]
666pub enum ParentAction {
667    /// Report the parent, direct children, child count and rollup for one issue
668    Show {
669        /// Issue number, filename fragment or unique title fragment
670        issue: String,
671    },
672    /// List an issue's direct children, ordered by issue number
673    Children {
674        /// Issue number, filename fragment or unique title fragment
675        issue: String,
676    },
677    /// List every issue that has children, with a child count
678    List,
679}
680
681/// The `tix workflow` verbs, per `contracts/cli-surface.md` §1.
682///
683/// A real `#[command(subcommand)]` tree rather than an optional-verb enum like
684/// [`LinkAction`]: the three verbs share nothing but the prefix, and there is no
685/// sensible bare `tix workflow`. Making the verb **required** means `tix workflow`
686/// alone is a usage error rather than an empty success — printing the vocabulary
687/// when the user asked for the policy would be guessing at their intent.
688#[derive(Subcommand)]
689pub enum WorkflowAction {
690    /// The global category vocabulary, in order
691    Categories,
692    /// A board's columns, resolved categories, and where each resolution came from
693    Show {
694        /// Board name; omitted means the active board (`default`, or the first
695        /// declared). A name that is not configured is an error listing the boards
696        /// that are.
697        board: Option<String>,
698    },
699    /// The effective transition policy and its override escape hatch
700    Transitions,
701}
702
703/// `tix layout` subcommands.
704#[derive(Subcommand)]
705pub enum LayoutAction {
706    /// Print the effective `[layout]` template and a worked example
707    Show,
708    /// Relocate every issue to the configured layout (dry run unless `--apply`)
709    Migrate {
710        /// Write the moves; without it the command only prints the plan
711        #[arg(long)]
712        apply: bool,
713    },
714}
715
716/// The `tix link` subcommands.
717///
718/// `tix link <from> <to> --type <kind>` is the create form and carries no
719/// subcommand, so the three verbs below are optional rather than required
720/// (C-001 removes the old untyped symmetric form, not the bare create form the
721/// CLI surface contract specifies).
722#[derive(Subcommand)]
723pub enum LinkAction {
724    /// Retarget and/or re-kind an existing relationship, keeping its id
725    Edit {
726        /// Relationship id, as printed by `tix link list`
727        id: String,
728        /// New target issue number, filename fragment or unique title fragment
729        #[arg(long)]
730        to: Option<String>,
731        /// New relationship kind: `blocks`, `blocked_by`, `duplicates`, `related`
732        #[arg(long, value_name = "KIND")]
733        r#type: Option<String>,
734    },
735    /// Remove a relationship by id. An absent id is an idempotent no-op
736    Remove {
737        /// Relationship id, as printed by `tix link list`
738        id: String,
739    },
740    /// List relationships for one issue, or for the whole board
741    List {
742        /// Issue number, filename fragment or unique title fragment. Omit for every issue.
743        issue: Option<String>,
744        /// Only relationships of this kind
745        #[arg(long, value_name = "KIND")]
746        kind: Option<String>,
747    },
748}
749
750/// `tix bulk <action>` — four actions, and each carries **its own** flags.
751///
752/// The flags are on the subcommand rather than on the parent, which is what makes the
753/// surface unambiguous by construction rather than by a runtime check:
754/// `tix bulk archive --priority high` is a **clap usage error**, not a command that
755/// accepts a field flag and ignores it. `edit` is the only action with field flags,
756/// `link`/`unlink` are the only ones with `--target`, and `archive` takes neither — so
757/// there is no spelling of `tix bulk` that a flag can apply to by accident.
758///
759/// `archive` has no `--type`: archiving is not a relationship, and offering a kind
760/// would suggest it is one.
761/// The flags every `tix bulk` action carries: which issues, and whether to write.
762///
763/// Flattened into **each** action rather than held on `Command::Bulk`, because clap
764/// parses a parent's flags *before* its subcommand — so a parent would spell the
765/// contract's `tix bulk edit --select <...>` as `tix bulk --select <...> edit`, which
766/// is a different surface from the one the contract names. Four `#[command(flatten)]`
767/// of one struct gives the contract's spelling and one vocabulary, so a new flag
768/// cannot be added to one action and forgotten on the other three.
769#[derive(Args, Debug, Clone, PartialEq, Eq)]
770pub struct BulkSelection {
771    /// Comma-separated issue selectors: number, filename fragment or unique title
772    /// fragment
773    #[arg(long, value_name = "LIST")]
774    pub select: Option<String>,
775    /// A `--filter` query, in the existing filter grammar
776    #[arg(long, value_name = "QUERY")]
777    pub filter: Option<String>,
778    /// A `--search` query, in the existing search grammar
779    #[arg(long, value_name = "QUERY")]
780    pub search: Option<String>,
781    /// Which issues are in play: `active`, `active+archived` or `archived`
782    #[arg(long, value_name = "SCOPE")]
783    pub scope: Option<String>,
784    /// Write the batch. Omit for a preview, which is the default.
785    #[arg(long)]
786    pub apply: bool,
787}
788
789impl BulkAction {
790    /// The shared selection flags, whichever action carries them.
791    ///
792    /// One accessor for four flattened fields, so the routing code reads the flags
793    /// once and a new action cannot forget to pass them.
794    pub fn selection(&self) -> &BulkSelection {
795        match self {
796            Self::Edit { selection, .. }
797            | Self::Link { selection, .. }
798            | Self::Unlink { selection, .. }
799            | Self::Archive { selection } => selection,
800        }
801    }
802
803    /// Whether `--apply` was given. A preview is the default, so this is a positive
804    /// question rather than "is this not a preview".
805    pub fn is_apply(&self) -> bool {
806        self.selection().apply
807    }
808}
809
810#[derive(Subcommand, Debug, Clone, PartialEq, Eq)]
811pub enum BulkAction {
812    /// Apply a typed field patch to every selected issue
813    Edit {
814        #[command(flatten)]
815        selection: BulkSelection,
816        /// Target column
817        #[arg(long, value_name = "COLUMN")]
818        status: Option<String>,
819        /// `low`, `medium`, `high`, or `` for unset
820        #[arg(long, value_name = "PRIORITY")]
821        priority: Option<String>,
822        /// Bare handle or full identity
823        #[arg(long, value_name = "ASSIGNEE")]
824        assignee: Option<String>,
825        /// Comma-separated labels
826        #[arg(long, value_name = "LABELS")]
827        labels: Option<String>,
828        /// `YYYY-MM-DD`
829        #[arg(long, value_name = "DATE")]
830        start_date: Option<String>,
831        /// `YYYY-MM-DD`
832        #[arg(long, value_name = "DATE")]
833        due_date: Option<String>,
834        /// Free-form estimate
835        #[arg(long, value_name = "ESTIMATE")]
836        estimate: Option<String>,
837        /// Comma-separated cycle names
838        #[arg(long, value_name = "CYCLES")]
839        cycles: Option<String>,
840        /// Comma-separated milestone names
841        #[arg(long, value_name = "MILESTONES")]
842        milestones: Option<String>,
843        /// Empty a field. Takes one of the clearable field names.
844        #[arg(long, value_name = "FIELD")]
845        clear: Option<String>,
846    },
847    /// Link every selected issue to `--target`
848    Link {
849        #[command(flatten)]
850        selection: BulkSelection,
851        /// The other end of the relationship
852        #[arg(long, value_name = "SELECTOR")]
853        target: String,
854        /// Relationship kind: `blocks`, `blocked_by`, `duplicates`, `related`
855        #[arg(long, value_name = "KIND")]
856        r#type: Option<String>,
857    },
858    /// Unlink every selected issue from `--target`
859    Unlink {
860        #[command(flatten)]
861        selection: BulkSelection,
862        /// The other end of the relationship
863        #[arg(long, value_name = "SELECTOR")]
864        target: String,
865        /// Relationship kind: `blocks`, `blocked_by`, `duplicates`, `related`
866        #[arg(long, value_name = "KIND")]
867        r#type: Option<String>,
868    },
869    /// Ask the archive service to archive every selected issue
870    Archive {
871        #[command(flatten)]
872        selection: BulkSelection,
873    },
874}
875
876/// Process entry: route a parsed [`Cli`] through config resolution, store
877/// opening and dispatch, then return the exit code for `main` to exit with.
878pub fn run(cli: Cli) -> u8 {
879    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
880    match &cli.command {
881        Some(Command::Lint { no_foreign }) => run_lint(&cwd, *no_foreign),
882        Some(Command::Mcp) => match run_mcp(&cwd) {
883            Ok(()) => ExitCode::Success as u8,
884            Err(e) => {
885                eprintln!("tix: {e}");
886                exit_code_for(&e) as u8
887            }
888        },
889        Some(Command::Render) => match run_render(&cwd) {
890            Ok(()) => ExitCode::Success as u8,
891            Err(e) => {
892                eprintln!("tix: {e}");
893                exit_code_for(&e) as u8
894            }
895        },
896        // A bulk batch is the one command whose **status** is a result rather than an
897        // error: a partial batch completes and reports non-zero, with every outcome
898        // retained (`contracts/cli-surface.md`). So it is routed here, where the exit
899        // code is decided, and not through `commands::dispatch`, whose arms are all
900        // `Result<()>`.
901        Some(Command::Bulk { action }) => {
902            let result = resolve(&cwd).and_then(|(_, store)| {
903                commands::bulk::run(
904                    &store,
905                    action,
906                    action.selection(),
907                    if action.is_apply() {
908                        commands::bulk::Mode::Apply
909                    } else {
910                        commands::bulk::Mode::Preview
911                    },
912                )
913            });
914            match result {
915                Ok(code) => code as u8,
916                Err(e) => {
917                    eprintln!("tix: {e}");
918                    exit_code_for(&e) as u8
919                }
920            }
921        }
922        // `tix triage` shares bulk's exit-code shape for the same reason: a batch
923        // that partly succeeded is a *completed* command with a status, not an
924        // error, so `--apply` can print one outcome per selected issue and still
925        // exit non-zero (T026). It is routed here rather than through
926        // `commands::dispatch`, whose arms are all `Result<()>`.
927        Some(Command::Triage { action }) => {
928            let result = resolve(&cwd).and_then(|(_, store)| commands::triage::run(&store, action));
929            match result {
930                Ok(code) => code as u8,
931                Err(e) => {
932                    eprintln!("tix: {e}");
933                    exit_code_for(&e) as u8
934                }
935            }
936        }
937        // `tix archive`/`tix restore` share the batch exit-code shape: every
938        // selector is processed independently, a failure on one does not roll back
939        // another, and the process still exits non-zero if any failed. Routed here
940        // for the same reason `tix bulk` and `tix triage` are.
941        Some(Command::Archive { issues }) => {
942            route_archive(&cwd, commands::archive::Action::Archive, issues)
943        }
944        Some(Command::Restore { issues }) => {
945            route_archive(&cwd, commands::archive::Action::Restore, issues)
946        }
947        _ => {
948            let result = match &cli.command {
949                None => run_tui(&cwd),
950                Some(Command::New) => run_tui_new(&cwd),
951                Some(Command::Init) => commands::init::run(&cwd),
952                Some(Command::InitAgents) => {
953                    let root = Config::project_root(&cwd).unwrap_or(cwd.clone());
954                    commands::agents::run(&root).map(|_| ())
955                }
956                Some(cmd) => run_subcommand(&cwd, cmd),
957            };
958            match result {
959                Ok(()) => ExitCode::Success as u8,
960                Err(e) => {
961                    eprintln!("tix: {e}");
962                    exit_code_for(&e) as u8
963                }
964            }
965        }
966    }
967}
968
969/// Process one `tix archive`/`tix restore` batch and map it to an exit code.
970///
971/// Each selector is independent: an outcome is printed per item and a failure on
972/// one item neither aborts the rest nor rolls back a successful rename. The
973/// process exits with the first failure's code, or `0` when every item succeeded.
974fn route_archive(cwd: &Path, action: commands::archive::Action, issues: &[String]) -> u8 {
975    match resolve(cwd).and_then(|(_, store)| commands::archive::run(&store, action, issues)) {
976        Ok(code) => code as u8,
977        Err(e) => {
978            eprintln!("tix: {e}");
979            exit_code_for(&e) as u8
980        }
981    }
982}
983
984/// `tix lint` owns its own boundary: its exit-code table (`0` healthy, `1`
985/// findings, `2` usage/internal) does not match the generic
986/// [`exit_code_for`] mapping, which would collapse internal errors into `1`.
987fn run_lint(cwd: &Path, no_foreign: bool) -> u8 {
988    let (config, store) = match resolve(cwd) {
989        Ok(pair) => pair,
990        Err(e) => {
991            eprintln!("tix: {e}");
992            return 2;
993        }
994    };
995    match commands::lint::run_with(&config, &store, !no_foreign) {
996        Ok(true) => 1,
997        Ok(false) => 0,
998        Err(e) => {
999            eprintln!("tix: {e}");
1000            2
1001        }
1002    }
1003}
1004
1005/// `tix` with no subcommand: launch the TUI, or fail with the contract's
1006/// exact message when no `tix.toml` is reachable (exit 1).
1007fn run_tui(cwd: &Path) -> Result<()> {
1008    let config = repo_config(cwd)?;
1009    crate::app::run(&config)
1010}
1011
1012/// `tix new`: the same session, opened on the create-issue form instead of the
1013/// board. Same repository resolution as [`run_tui`], so a missing `tix.toml` fails
1014/// with the same message rather than opening a form that could not be submitted.
1015fn run_tui_new(cwd: &Path) -> Result<()> {
1016    let config = repo_config(cwd)?;
1017    crate::app::run_new_issue(&config)
1018}
1019
1020/// Strict repository resolution shared by the TUI and `tix mcp` (FR-015):
1021/// upward `tix.toml` discovery, failing with the contract's exact `no
1022/// tix.toml found` guidance (exit 1) when no repository is reachable —
1023/// never a guessed default store. This is the "CLI hot path" that
1024/// `contracts/mcp-protocol.md` §7 requires `tix mcp` to reuse.
1025fn repo_config(cwd: &Path) -> Result<Config> {
1026    let root = Config::project_root(cwd)
1027        .ok_or_else(|| TixError::Config("no tix.toml found — run \"tix init\" first".into()))?;
1028    let mut config = Config::load(&root)?;
1029    // `tags_dir`, `[repos]` and `parent_repo` are relative to the project root
1030    // (tix #1529/#1530), so the TUI can spawn tix in a child from anywhere.
1031    config.resolve_repo_paths(&root);
1032    Ok(config)
1033}
1034
1035/// `tix mcp`: strict repository resolution (same hot path as `tix` with no
1036/// subcommand), then serve the MCP protocol synchronously over stdio. Outside
1037/// a repository the process fails with exit 1 BEFORE reading any protocol
1038/// bytes (FR-016, `contracts/mcp-protocol.md` §7).
1039fn run_mcp(cwd: &Path) -> Result<()> {
1040    let config = repo_config(cwd)?;
1041    let store = Store::open(&config)?;
1042    // Absolutize milestones against the same project root as issues_dir so the
1043    // MCP `create_issue`/`update_issue` planning validators and the CLI resolve
1044    // milestone files to the identical directory (parity cross-checker).
1045    let mut config = config;
1046    if config.milestones_dir().is_relative() {
1047        let root = Config::project_root(cwd).unwrap_or_else(|| cwd.to_path_buf());
1048        let configured = config.milestones_dir();
1049        config.milestones_dir = Some(root.join(configured));
1050    }
1051    crate::mcp::serve(&config, &store)
1052}
1053
1054/// `tix render`: strict repository resolution like `tix`/`tix mcp` so the
1055/// command fails with the same no-config guidance outside a repository.
1056fn run_render(cwd: &Path) -> Result<()> {
1057    let config = repo_config(cwd)?;
1058    let store = Store::open(&config)?;
1059    commands::dispatch(&Command::Render, &config, &store)
1060}
1061
1062/// Config resolution for subcommands: defaults when no `tix.toml` exists
1063/// (relative to the project root via upward walk), with a one-time stderr
1064/// notice. Only explicit per-command requirements fail hard.
1065fn resolve(cwd: &Path) -> Result<(Config, Store)> {
1066    let root = Config::project_root(cwd);
1067    let mut config = match &root {
1068        Some(r) => Config::load(r)?,
1069        None => Config::default(),
1070    };
1071    // Resolve a relative issues_dir against the project root (or cwd when
1072    // unconfigured) so subcommands behave identically from any subdirectory.
1073    if config.issues_dir.is_relative() {
1074        let base = root.as_deref().unwrap_or(cwd);
1075        config.issues_dir = base.join(&config.issues_dir);
1076    }
1077    // Milestones resolve against the same base as issues_dir: the planning
1078    // validators (create/edit) and `lint` must agree on where milestone
1079    // registry files live regardless of the invocation directory.
1080    if config.milestones_dir().is_relative() {
1081        let base = root.as_deref().unwrap_or(cwd);
1082        let configured = config.milestones_dir();
1083        config.milestones_dir = Some(base.join(configured));
1084    }
1085    // `tags_dir`, `[repos]` and `parent_repo` resolve against the same base.
1086    config.resolve_repo_paths(root.as_deref().unwrap_or(cwd));
1087    if root.is_none() {
1088        eprintln!("tix: no tix.toml found (run 'tix init' to create one)");
1089    }
1090    let store = Store::open(&config)?;
1091    Ok((config, store))
1092}
1093
1094fn run_subcommand(cwd: &Path, cmd: &Command) -> Result<()> {
1095    let (config, store) = resolve(cwd)?;
1096    if !matches!(cmd, Command::Rollover { .. }) {
1097        let planner = crate::planning::Planner::from_config(config.clone(), cwd.to_path_buf());
1098        let plan = planner.rollover_plan(&store, &crate::model::today());
1099        if let Some(first) = plan.first() {
1100            eprintln!(
1101                "hint: rollover of {} issue(s) from \"{}\" to \"{}\" is suggested; run 'tix rollover' to apply",
1102                first.issues.len(),
1103                first.from_cycle,
1104                first.to_cycle
1105            );
1106        }
1107    }
1108    commands::dispatch(cmd, &config, &store)
1109}