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
23mod cli_analytics;
24mod cli_beads;
25mod cli_bulk;
26mod cli_capacity;
27mod cli_cycle_schedule;
28mod cli_epic;
29mod cli_health;
30mod cli_initiative;
31mod cli_mentions;
32mod cli_milestone;
33mod cli_project;
34mod cli_recurring;
35mod cli_roadmap;
36mod cli_templates;
37mod cli_time;
38mod cli_triage;
39mod cli_views;
40mod cli_wip;
41mod cli_workflow;
42pub use cli_analytics::*;
43pub use cli_beads::*;
44pub use cli_bulk::*;
45pub use cli_capacity::*;
46pub use cli_cycle_schedule::*;
47pub use cli_epic::*;
48pub use cli_health::*;
49pub use cli_initiative::*;
50pub use cli_mentions::*;
51pub use cli_milestone::*;
52pub use cli_project::*;
53pub use cli_recurring::*;
54pub use cli_roadmap::*;
55pub use cli_templates::*;
56pub use cli_time::*;
57pub use cli_triage::*;
58pub use cli_views::*;
59pub use cli_wip::*;
60pub use cli_workflow::*;
61
62/// Exit codes frozen by `contracts/cli.md`.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum ExitCode {
65    Success = 0,
66    UsageConfig = 1,
67    NotFoundAmbiguous = 2,
68    ExternalTool = 3,
69}
70
71/// Map a domain error to the frozen exit-code table.
72///
73/// Selection ambiguity shares exit `2` with target-not-found, per the
74/// contract's exit-code table ("2 target-not-found or selection-ambiguous");
75/// the ambiguity marker is the [`commands::AMBIGUITY_PREFIX`] message prefix.
76pub fn exit_code_for(err: &TixError) -> ExitCode {
77    match err {
78        TixError::NotFound(_) => ExitCode::NotFoundAmbiguous,
79        TixError::InvalidInput(s) if s.starts_with(commands::AMBIGUITY_PREFIX) => {
80            ExitCode::NotFoundAmbiguous
81        }
82        TixError::External(_) => ExitCode::ExternalTool,
83        _ => ExitCode::UsageConfig,
84    }
85}
86
87#[derive(Parser)]
88#[command(
89    name = "tix",
90    version,
91    about = "Terminal issue tracker: markdown issues with a kanban TUI and CLI"
92)]
93pub struct Cli {
94    #[command(subcommand)]
95    pub command: Option<Command>,
96}
97
98#[derive(Subcommand)]
99pub enum Command {
100    /// Create a default tix.toml ([kanban.default], issues_dir, [export]).
101    ///
102    /// When `bd` is on PATH and a beads workspace is reachable, an interactive
103    /// run also offers the beads modes; `--beads` decides without a prompt, and a
104    /// non-interactive run never prompts (it keeps `off` unless `--beads` says
105    /// otherwise).
106    Init {
107        /// Beads mode to record: off | mirror | sync
108        ///
109        /// `mirror` reads a beads workspace through `bd` for viewing only;
110        /// `sync` installs the `.beads/hooks` that pull beads changes into tix.
111        #[arg(long, value_name = "off|mirror|sync")]
112        beads: Option<String>,
113    },
114    /// Append or replace the tix block in AGENTS.md (idempotent)
115    InitAgents {
116        /// Overwrite an outdated block without prompting
117        #[arg(long)]
118        yes: bool,
119    },
120    /// Print the crate version and how this binary was built
121    ///
122    /// Answers the question `--version` cannot: whether the binary on disk came
123    /// from the source on disk. It reports the commit it was built from and when,
124    /// so a rebuild that did not happen is visible. Needs no `tix.toml`, so it
125    /// works in any directory.
126    Version,
127    /// Read, or write, a [beads](https://github.com/gastownhall/beads) workspace
128    /// through the `bd` CLI
129    ///
130    /// tix never opens the beads Dolt database: every verb here shells out to
131    /// `bd`. The reads (`detect`, `status`, `list`, `show`, `watch`) write
132    /// nothing anywhere; the writes (`push`, `pull`, `reconcile`) are refused
133    /// while the beads mode is `mirror`, which is read-only by contract. See
134    /// `docs/for-users/beads-integration.md`.
135    Beads {
136        #[command(subcommand)]
137        action: BeadsAction,
138    },
139    /// Create an issue
140    Create {
141        /// Issue title (prompted when missing on a TTY)
142        #[arg(long)]
143        title: Option<String>,
144        /// Issue body (markdown)
145        #[arg(long)]
146        body: Option<String>,
147        /// Column name (default: first column)
148        #[arg(long)]
149        status: Option<String>,
150        /// Priority: low | medium | high (default medium)
151        #[arg(long)]
152        priority: Option<String>,
153        /// Assignee (@name)
154        #[arg(long)]
155        assignee: Option<String>,
156        /// Labels, comma-separated
157        #[arg(long)]
158        labels: Option<String>,
159        /// Files governing this issue, comma-separated
160        #[arg(long)]
161        files: Option<String>,
162        /// Estimate
163        #[arg(long)]
164        estimate: Option<String>,
165        /// Cycle names, comma-separated
166        #[arg(long)]
167        cycles: Option<String>,
168        /// Milestone names, comma-separated
169        #[arg(long)]
170        milestones: Option<String>,
171        /// Projects to associate the new issue with: ids or names, comma-separated
172        ///
173        /// Every reference is resolved through the project service **before** the
174        /// issue is written, so an unknown id refuses the whole create rather than
175        /// leaving a half-associated issue. This flag lives on the shared create
176        /// path, which is the handoff point a future template surface (source issue
177        /// #174) would populate (mission `first-class-projects-01M3BYBW`).
178        #[arg(long, value_name = "ID")]
179        projects: Option<String>,
180        /// Epics the new issue references: ids or names, comma-separated
181        ///
182        /// Every reference is resolved through the epic service **before** the issue
183        /// is written, so an unknown id refuses the whole create rather than leaving
184        /// a half-referenced issue. This flag lives on the shared create path, which
185        /// is the handoff point a future template surface (source issue #174) would
186        /// populate. Referencing an epic never changes how the issue itself is
187        /// navigated: it stays an ordinary issue (mission `epics-work-items-01M3BYW4`).
188        #[arg(long, value_name = "ID")]
189        epics: Option<String>,
190        /// Reviewer (free-form name/identity)
191        #[arg(long)]
192        reviewer: Option<String>,
193        /// Start date (`YYYY-MM-DD`)
194        #[arg(long)]
195        start_date: Option<String>,
196        /// Due date (`YYYY-MM-DD`); must be `>= start_date`
197        #[arg(long)]
198        due_date: Option<String>,
199        /// Create as a child of this issue (number, filename or title fragment)
200        ///
201        /// At most one parent, so this takes a single selector and there is no list
202        /// form (C-001). The parent must already exist; a selector that matches no
203        /// issue is refused and nothing is created.
204        #[arg(long, value_name = "SELECTOR")]
205        parent: Option<String>,
206        /// Related issues, comma-separated: a local number/selector, a relative
207        /// or absolute path to another issue file, or a repository URL
208        /// (`host/path#id`). Free-form; stored verbatim.
209        #[arg(long)]
210        related_issues: Option<String>,
211        /// Issue type (e.g. `bug`, `feature`); `tix lint` warns on a value not in
212        /// `custom_types`.
213        #[arg(long)]
214        r#type: Option<String>,
215        /// Create from a stable template ID (`tix template list`)
216        ///
217        /// The template's defaults and prompts are merged with the flags above
218        /// by the pure resolver, then the resolved request goes through the
219        /// shared create core — there is no second issue writer. Resolution
220        /// never prompts: a required prompt with no value is a diagnostic, the
221        /// template and the store are left untouched, and no issue is written.
222        #[arg(long, value_name = "STABLE-ID")]
223        template: Option<String>,
224        /// Template scope: `repository` | `board:<name>` | `team:<label>`
225        ///
226        /// Only meaningful with `--template` (clap requires it). An omitted
227        /// scope is the explicit `repository` default, never an implicit
228        /// inheritance. The selected template ID and scope are echoed on
229        /// stderr so stdout keeps the `Created <file>` shape.
230        #[arg(long, value_name = "SCOPE", requires = "template")]
231        scope: Option<String>,
232    },
233    /// Create, edit, remove, or list typed relationships
234    ///
235    /// Creating requires `--type`; there is no default kind, so an untyped
236    /// relationship is never recorded by accident (C-001). Omit the
237    /// subcommand to create: `tix link <from> <to> --type <kind>`.
238    Link {
239        /// `edit`, `remove`, or `list`. Omit to create a relationship.
240        ///
241        /// These three words are reserved on `tix link`: an issue whose number,
242        /// filename, or title selector is exactly `edit`, `remove`, or `list`
243        /// must be addressed by number.
244        #[command(subcommand)]
245        action: Option<LinkAction>,
246        /// [create] Issue number, filename fragment or unique title fragment
247        from: Option<String>,
248        /// [create] Issue number, filename fragment or unique title fragment
249        to: Option<String>,
250        /// [create] Relationship kind: `blocks`, `blocked_by`, `duplicates`, `related`
251        #[arg(long, value_name = "KIND")]
252        r#type: Option<String>,
253    },
254    /// Batch editing over a selection
255    ///
256    /// Preview is the **default**; `--apply` writes. Name exactly one of `--select`,
257    /// `--filter` or `--search` — they are different grammars, and a bulk operation
258    /// will not guess which issues you meant.
259    Bulk {
260        #[command(subcommand)]
261        action: BulkAction,
262    },
263    /// Remove a typed relationship between two issues
264    Unlink {
265        /// Issue number, filename fragment or unique title fragment
266        from: String,
267        /// Issue number, filename fragment or unique title fragment
268        to: String,
269        /// Relationship kind: `blocks`, `blocked_by`, `duplicates`, `related`
270        #[arg(long, value_name = "KIND")]
271        r#type: Option<String>,
272    },
273    /// Print the transitive dependency chain an issue holds up
274    Chain {
275        /// Issue number, filename fragment or unique title fragment
276        issue: String,
277    },
278    /// Inspect the issue hierarchy
279    ///
280    /// `show`, `children`, or `list`. Every figure comes from the hierarchy
281    /// service's typed values, so the CLI, the TUI detail block and the MCP read
282    /// tools report the same numbers for the same issue.
283    Parent {
284        /// `show`, `children`, or `list`
285        #[command(subcommand)]
286        action: ParentAction,
287    },
288    /// Edit an issue
289    Edit {
290        /// Issue number, filename fragment or unique title fragment
291        issue: String,
292        /// New title
293        #[arg(long)]
294        title: Option<String>,
295        /// New body (markdown)
296        #[arg(long)]
297        body: Option<String>,
298        /// New status
299        ///
300        /// Setting the status is a **move**, validated by the same service as
301        /// `tix mv` and recorded as the same `TransitionDecision` — it is not a
302        /// way around the transition policy. `--status ""` clears the field
303        /// (matching every other field here) and is a plain field write, not a
304        /// transition, because there is no target column to judge.
305        #[arg(long)]
306        status: Option<String>,
307        /// New priority: low | medium | high
308        #[arg(long)]
309        priority: Option<String>,
310        /// New assignee (@name)
311        #[arg(long)]
312        assignee: Option<String>,
313        /// New label list, comma-separated
314        #[arg(long)]
315        labels: Option<String>,
316        /// New files list, comma-separated
317        #[arg(long)]
318        files: Option<String>,
319        /// New estimate: `3`, `3d`, `1 day`, `0.5-1 day`, `3 weeks 2 hours` (empty clears)
320        #[arg(long)]
321        estimate: Option<String>,
322        /// New cycle list, comma-separated (`--cycles ""` clears)
323        #[arg(long)]
324        cycles: Option<String>,
325        /// New milestone list, comma-separated (`--milestones ""` clears)
326        #[arg(long)]
327        milestones: Option<String>,
328        /// Associate the issue with a project (id or name); repeatable
329        ///
330        /// Idempotent: an already-present association is a no-op with no History
331        /// entry. The reference is resolved through the project service before
332        /// anything is written, so an unknown project refuses the edit and leaves
333        /// the file byte-identical.
334        #[arg(long, value_name = "ID")]
335        add_project: Vec<String>,
336        /// Dissociate the issue from a project (id or name); repeatable
337        ///
338        /// Drops only the association — the issue is never deleted. A known project
339        /// that is not associated is a no-op; an unresolvable reference that the
340        /// issue does not carry is refused as `unknown_project`.
341        #[arg(long, value_name = "ID")]
342        remove_project: Vec<String>,
343        /// Replace the whole association list, comma-separated
344        ///
345        /// Only a blank value is accepted here: `--projects ""` clears every
346        /// association, mirroring the empty-string-clears convention the other list
347        /// flags follow. To add or drop individual associations use
348        /// `--add-project` / `--remove-project`; a non-blank value is refused rather
349        /// than guessed at.
350        #[arg(long, value_name = "IDS")]
351        projects: Option<String>,
352        /// Add an epic reference (id or name); repeatable
353        ///
354        /// Idempotent: an already-present reference is a no-op with no History
355        /// entry. The reference is resolved through the epic service before
356        /// anything is written, so an unknown epic refuses the edit and leaves the
357        /// file byte-identical.
358        #[arg(long = "add-epic", value_name = "ID")]
359        add_epic: Vec<String>,
360        /// Remove an epic reference; repeatable
361        ///
362        /// Drops only the reference — the issue is never deleted. A known epic that
363        /// is not referenced is a no-op; an unresolvable reference the issue does not
364        /// carry is refused as `unknown_epic`. A retained stale id can still be
365        /// removed by naming it verbatim.
366        #[arg(long = "remove-epic", value_name = "ID")]
367        remove_epic: Vec<String>,
368        /// Clear every epic reference
369        ///
370        /// Only a blank value is accepted here: `--epics ""` clears every reference,
371        /// mirroring the empty-string-clears convention `--projects` follows. To add
372        /// or drop individual references use `--add-epic` / `--remove-epic`; a
373        /// non-blank value is refused rather than guessed at.
374        #[arg(long, value_name = "IDS")]
375        epics: Option<String>,
376        /// New reviewer (`--reviewer ""` clears)
377        #[arg(long)]
378        reviewer: Option<String>,
379        /// New start date (`YYYY-MM-DD`; `--start-date ""` clears)
380        #[arg(long)]
381        start_date: Option<String>,
382        /// New due date (`YYYY-MM-DD`; `--due-date ""` clears)
383        #[arg(long)]
384        due_date: Option<String>,
385        /// Commit hash to record (`auto` resolves it from git history, `clear` removes it)
386        #[arg(long)]
387        commit: Option<String>,
388        /// Set or change the parent (number, filename or title fragment)
389        ///
390        /// `--parent ""` clears the parent, mirroring `--assignee ""`; omitting the
391        /// flag leaves it untouched. Validated before any write, so a self-parent,
392        /// an unknown parent, or a change that would close a cycle leaves the file
393        /// byte-identical.
394        #[arg(long, value_name = "SELECTOR")]
395        parent: Option<String>,
396        /// Why a transition policy is being overridden by `--status`
397        ///
398        /// `--status` is validated exactly as `tix mv` is, so moving an issue out
399        /// of a terminal category needs the same reasoned override, and the reason
400        /// is recorded in the same History decision. `--status` deliberately has
401        /// no `--yes`: acknowledging blockers is spelled `tix move … --yes`, and
402        /// giving this flag a second spelling would give the audit trail two.
403        #[arg(long, value_name = "TEXT")]
404        reason: Option<String>,
405        /// New related-issues list, comma-separated (`--related-issues ""` clears).
406        /// A local number/selector, a relative or absolute path to another issue
407        /// file, or a repository URL (`host/path#id`).
408        #[arg(long)]
409        related_issues: Option<String>,
410        /// New issue type (`--type ""` clears it); `tix lint` warns on a value not
411        /// in `custom_types`.
412        #[arg(long)]
413        r#type: Option<String>,
414    },
415    /// Move one or more issues to a column
416    ///
417    /// A single selector behaves exactly as before. More than one position,
418    /// `--select`, `--filter` or `--search` moves the whole selection; a batch
419    /// larger than one requires `--yes` to apply, and `--dry-run` previews it.
420    Move {
421        /// Issue selectors followed by the target column. The **last** value is
422        /// the column; the preceding ones are selectors (number, filename
423        /// fragment, unique title fragment, comma- or space-separated).
424        #[arg(value_name = "ISSUE_OR_COLUMN", num_args = 1.., required = true)]
425        args: Vec<String>,
426        /// Comma-separated issue selectors (same grammar as the positional list)
427        #[arg(long, value_name = "LIST")]
428        select: Option<String>,
429        /// A `--filter` query, in the existing filter grammar
430        #[arg(long, value_name = "QUERY")]
431        filter: Option<String>,
432        /// A `--search` query, in the existing search grammar
433        #[arg(long, value_name = "QUERY")]
434        search: Option<String>,
435        /// Print the batch plan and write nothing
436        #[arg(long)]
437        dry_run: bool,
438        /// Commit hash to record when moving to a completion column (`auto`
439        /// resolves it from git history; omitted, the current HEAD is captured
440        /// automatically)
441        ///
442        /// "Completion" is whatever the board's category mapping calls
443        /// `completed` — not a column literally named `done`. On a board with
444        /// no `[kanban.<name>.category_map]` the board's `done_column` is the
445        /// completion column.
446        #[arg(long, value_name = "HASH")]
447        commit: Option<String>,
448        /// Why a transition policy is being overridden
449        ///
450        /// Required to move an issue *out of* a terminal category (`completed`
451        /// or `canceled`) when the board declares a `category_map`. The reason
452        /// is recorded in the issue's History, so the audit trail explains
453        /// itself later. A blank value is not a reason and is refused.
454        ///
455        /// A board with no `category_map` has no transition policy to override:
456        /// it keeps the completion definition and the commit rule, and moving
457        /// out of `done` stays permitted.
458        #[arg(long, value_name = "TEXT")]
459        reason: Option<String>,
460        /// Confirm without prompting
461        ///
462        /// Also the acknowledgement a move toward the board's completion column
463        /// needs when the issue has open blockers. Without it that move is
464        /// refused, and a recorded acknowledgement is appended to the issue's
465        /// History so the decision stays auditable.
466        ///
467        /// For a selection larger than one, `--yes` is also the batch
468        /// confirmation: without it the plan is printed and nothing is written.
469        #[arg(long)]
470        yes: bool,
471        /// Authorize overriding a **blocking work-in-progress limit**
472        ///
473        /// `--override` is a request, not an authorization. The move only proceeds
474        /// when `--reason` states why and `--yes` confirms it, and the override is
475        /// recorded — actor, timestamp, reason and the limit it was authorized
476        /// against — as a History entry on the issue. Without `--override` a move
477        /// that would exceed a `block` limit is refused and nothing is written; a
478        /// `warn` limit never needs one.
479        #[arg(long)]
480        r#override: bool,
481    },
482    /// Inspect or configure work-in-progress limits
483    ///
484    /// Limits live in `tix.toml` under `[kanban.<board>.wip]`: a column's ceilings
485    /// (`max_items`, `max_estimate`), the unit an estimate ceiling counts in, and
486    /// whether exceeding it `warn`s or `block`s. A board with no limits behaves
487    /// exactly as it did before they existed.
488    Wip {
489        #[command(subcommand)]
490        action: WipAction,
491    },
492    /// Print an issue, or manage saved views (#172)
493    ///
494    /// `tix view <issue>` prints the issue file, unchanged. Without an issue
495    /// selector the subcommands below manage repository-owned **saved views**:
496    /// named, stable-ID query definitions with explicit scope metadata and a
497    /// normalized plan over the existing filter/sort/group/visibility grammar.
498    ///
499    /// The nine subcommand words (`list`, `show`, `create`, `update`,
500    /// `duplicate`, `delete`, `apply`, `status`, `share`) are **reserved** on
501    /// `tix view`: an issue whose selector is exactly one of them must be
502    /// addressed by number, exactly as `tix link` reserves `edit` / `remove` /
503    /// `list`.
504    #[command(visible_alias = "show")]
505    View {
506        /// `list`, `show`, `create`, `update`, `duplicate`, `delete`, `apply`,
507        /// `status`, or `share`. Omit to print an issue.
508        #[command(subcommand)]
509        action: Option<ViewAction>,
510        /// [print] Issue number, filename fragment or unique title fragment
511        issue: Option<String>,
512        /// [print] Extra information to append, comma-separated
513        ///
514        /// `relationships` appends the service-derived relationship block, the
515        /// transitive chain, and the blocked reason. Without it the issue file
516        /// is printed verbatim, so `tix view <n> > file` still round-trips.
517        #[arg(long, value_name = "relationships")]
518        show: Option<String>,
519    },
520    /// Manage recurring issue series (tix #175)
521    ///
522    /// A series is a **normal issue** that carries a `series:` table in its
523    /// frontmatter: the issue holds the rule, the lifecycle state, and the
524    /// template an occurrence is created from; each occurrence is another normal
525    /// issue carrying the series id, the date and the idempotency key.
526    ///
527    /// `detect` is read-only and reports what is due; `generate` and every
528    /// lifecycle action need `--yes`, because no refresh, timer or detection may
529    /// change a schedule on its own.
530    Recurring {
531        #[command(subcommand)]
532        action: RecurringAction,
533    },
534    /// One line per issue: number, id and title
535    ///
536    /// The default listing is **scoped**: your issues (the git author identity,
537    /// when it can be derived) in the board's entry column — `todo` on the board
538    /// `tix init` writes. `--all` drops the scope, and `--status`/`--filter`
539    /// replace it. The title is cropped to the terminal's width, so every issue
540    /// occupies exactly one line; the full board is `tix render`.
541    List {
542        /// Only issues in this status
543        #[arg(long)]
544        status: Option<String>,
545        /// Token query (`cycle:`, `milestone:`, `reviewer:`, `overdue`, etc.)
546        #[arg(long)]
547        filter: Option<String>,
548        /// Restrict the listing to one project (stable id, or a display name)
549        ///
550        /// Resolved through the project service and applied through the shared
551        /// `project:` criterion, so `tix list --project <id>` and
552        /// `tix search "project:<id>"` return the same matching set.
553        #[arg(long)]
554        project: Option<String>,
555        /// Restrict the listing to one epic (stable id, or a display name)
556        ///
557        /// Resolved through the epic service and applied through the shared
558        /// `epic:` criterion, so `tix list --epic <id>` and
559        /// `tix search "epic:<id>"` return the same matching set. An unknown
560        /// reference matches nothing, so a typo yields a visible empty listing
561        /// rather than a silent unfiltered one.
562        #[arg(long)]
563        epic: Option<String>,
564        /// Include archived issues, marked with an ARCHIVED column
565        ///
566        /// Off by default, and off for a reason: archived issues are skipped
567        /// without being read, so a repository with thousands of them lists fast.
568        #[arg(long)]
569        with_archived: bool,
570        /// Sort key (`created`, `due`, `start`, `reviewer`, `cycle`, `milestone`, `project`, `epic`, `status`, `priority`)
571        #[arg(long)]
572        sort: Option<String>,
573        /// Group key (`cycle` | `milestone` | `project` | `epic`)
574        #[arg(long)]
575        group: Option<String>,
576        /// Comma-separated columns to display
577        ///
578        /// The full set: `num`, `id`, `title`, `status`, `priority`, `cycle`,
579        /// `milestone`, `reviewer`, `start`, `due`, `overdue`, `blocked`,
580        /// `relations`, `progress`, `projects`, `epics`, `category`, `archived`,
581        /// `wip`, `health`. The `value_name` used to list only the first ten, so a
582        /// column this build had supported for two missions looked unsupported in
583        /// `--help`; a column left out of this list is the same lie about a smaller
584        /// set.
585        #[arg(long, value_name = "COLUMNS")]
586        columns: Option<String>,
587        /// Extra information to include, comma-separated
588        ///
589        /// `relationships` adds a relationship summary and a blocked marker
590        /// per issue, read from the relationship service so the CLI and the
591        /// TUI cannot disagree.
592        ///
593        /// `progress` adds a parent rollup; `category` adds the issue's resolved
594        /// category label, read from the shared service so this surface and the
595        /// TUI cannot spell one category two ways.
596        ///
597        /// `wip` adds the column's usage against its limit, and `health` adds the
598        /// issue's health (its own value, with the derived rollup beside it when the
599        /// two differ), read from the one portable health document.
600        #[arg(long, value_name = "SHOW")]
601        show: Option<String>,
602        /// List every issue, ignoring the default scope
603        ///
604        /// The default listing is scoped to your issues in the board's entry
605        /// column. `--all` drops both halves of that scope — the listing is then
606        /// every active issue, exactly as it was before the scope existed.
607        #[arg(long)]
608        all: bool,
609        /// Emit a structured document instead of the table: `xml`, `yml` or `toml`
610        ///
611        /// One record per issue, keyed by the columns being displayed, under the
612        /// single root `issues` — so `--columns` chooses the fields. JSON is
613        /// deliberately not one of them.
614        #[arg(long, value_name = "FORMAT")]
615        format: Option<String>,
616    },
617    /// The next issue to start: unblocked, highest priority first
618    ///
619    /// The `tix list` a working session opens with. It shows every issue that
620    /// nothing blocks and that has not begun — a column the board maps to `backlog`
621    /// or `unstarted`, no further along than the column new work lands in — ordered
622    /// by priority (highest first, then oldest first), so the **first row** is the
623    /// issue to pick up next. Blocked means an **open** blocker: the same graph the
624    /// `blocked` column reads, so finishing a blocker returns its dependents here.
625    Ready {
626        /// Comma-separated columns to display
627        ///
628        /// The default set is `num,id,priority,title`: the identity pair, the
629        /// priority the list is ordered by, and the title.
630        #[arg(long, value_name = "COLUMNS")]
631        columns: Option<String>,
632        /// Show at most this many issues
633        ///
634        /// Applied after the ordering, so `--limit 1` is the single next issue.
635        #[arg(long, value_name = "N")]
636        limit: Option<usize>,
637        /// Restrict the listing to one project (stable id, or a display name)
638        #[arg(long)]
639        project: Option<String>,
640        /// Restrict the listing to one epic (stable id, or a display name)
641        #[arg(long)]
642        epic: Option<String>,
643        /// Emit a structured document instead of the table: `xml`, `yml` or `toml`
644        #[arg(long, value_name = "FORMAT")]
645        format: Option<String>,
646    },
647    /// Inspect the status-category vocabulary, a board's mapping, and the
648    /// effective transition policy
649    ///
650    /// A read-only surface. It never writes and never repairs, so it is safe to run
651    /// on a repository whose `tix.toml` is mid-edit.
652    Workflow {
653        #[command(subcommand)]
654        action: WorkflowAction,
655    },
656    /// Board summary: count per column + total
657    Status {
658        /// Token query (`cycle:`, `milestone:`, `reviewer:`, `overdue`, etc.)
659        #[arg(long)]
660        filter: Option<String>,
661        /// Sort key (`created`, `due`, `start`, `reviewer`, `cycle`, `milestone`, `status`, `priority`)
662        #[arg(long)]
663        sort: Option<String>,
664        /// Group key (`cycle` | `milestone` | `project` | `epic`)
665        #[arg(long)]
666        group: Option<String>,
667        /// Add the health summary: issue counts per state, with diagnostics
668        ///
669        /// Read from the same portable health document `tix health board` and the
670        /// TUI render, so the figures are the same figures. Off by default: the board
671        /// summary is about columns, and health is metadata about the work (C-006).
672        #[arg(long)]
673        health: bool,
674        /// Print one machine-readable JSON object instead of the table
675        ///
676        /// The object is the board summary: `columns` in board order, each with
677        /// its count, and `total`. With `--health` the same `health` object MCP's
678        /// `board_status` returns is added; without it the key is absent. The
679        /// table without this flag is unchanged.
680        #[arg(long)]
681        json: bool,
682    },
683    /// Project and issue health: read a scope, roll it up, or record a change
684    ///
685    /// Health is **metadata**, not the board's status: it never replaces a status
686    /// category and never means "done". The current value lives in an issue's
687    /// frontmatter, every change is an append-only History entry carrying an actor, a
688    /// date and a rationale, and a scope's derived value comes from what contributes
689    /// to it under a versioned policy (`health-v1`).
690    ///
691    /// `set` and `override` are the mutations, and both refuse — having written
692    /// nothing — when an audit field is missing. `show`, `rollup`, `history` and
693    /// `board` read.
694    Health {
695        #[command(subcommand)]
696        action: HealthAction,
697    },
698    /// Read and act on your `@mentions` (issue-mentions)
699    ///
700    /// A mention is `@handle` or `@name@host` in an issue's title or body, recorded
701    /// per mentioned target under `docs/mentions/`. This command is the
702    /// **non-interactive** surface: it lists what is pending for you and applies the
703    /// three answers without a prompt, so the flow works from a script and without a
704    /// TTY. The TUI has the same three answers on the bottom indicator row's chord.
705    Mentions {
706        #[command(subcommand)]
707        action: MentionsAction,
708    },
709    /// Triage the inbox: read-only queue views and the four decisions
710    Triage {
711        #[command(subcommand)]
712        action: TriageAction,
713    },
714    /// Delete an issue file
715    Delete {
716        /// Issue number, filename fragment or unique title fragment
717        issue: String,
718        /// Confirm without prompting
719        #[arg(long)]
720        yes: bool,
721    },
722    /// Archive issue files: rename each in place to a dot-prefixed name
723    ///
724    /// The default views (`tix list`, `tix search`) skip archived issues; pass
725    /// their `--with-archived` flag or the `archived:true` filter to see them.
726    Archive {
727        /// Issue numbers, filename fragments or unique title fragments
728        #[arg(required = true, value_name = "ISSUE")]
729        issues: Vec<String>,
730    },
731    /// Restore archived issue files: the inverse of `tix archive`
732    Restore {
733        /// Issue numbers, filename fragments or unique title fragments
734        #[arg(required = true, value_name = "ISSUE")]
735        issues: Vec<String>,
736    },
737    /// Manage planning cycles
738    Cycle {
739        #[command(subcommand)]
740        command: CycleCommand,
741    },
742    /// Configure and inspect cycle, team and assignee capacity
743    ///
744    /// A ceiling is a scope (one cycle, one team label, or one assignee identity)
745    /// with a non-negative value in an estimate unit the active board declares.
746    /// `calculate` reports the shared committed/completed/remaining/overflow/
747    /// utilization values and never mutates an issue; `snapshot` reads back the
748    /// immutable evidence a cycle close captured.
749    Capacity {
750        #[command(subcommand)]
751        command: CapacityCommand,
752    },
753    /// Record and review actual effort against issues, in whole minutes
754    ///
755    /// A time entry is a duration, an optional `YYYY-MM-DD` calendar date, an author
756    /// and an optional note — there is no timer, no timezone conversion and no overlap
757    /// rule. Current entries live in the issue's own frontmatter, every correction and
758    /// deletion appends an attributable History event, and a deletion removes the
759    /// current value only. `add`, `edit` and `delete` are the mutations (`edit` and
760    /// `delete` require `--reason`; `delete` also requires `--confirm`), and `show`,
761    /// `history` and `total` are reads whose numbers come from one shared calculation.
762    Time {
763        #[command(subcommand)]
764        command: TimeCommand,
765    },
766    /// Manage milestones
767    Milestone {
768        #[command(subcommand)]
769        command: MilestoneCommand,
770    },
771    /// Create, edit, list, inspect, delete and populate projects
772    ///
773    /// A project is a stable id (a registry key in `tix.toml`) with a mutable
774    /// display name. An issue carries **project ids**, so a rename breaks no
775    /// association: the id is the identity and the name is a label. Every listing
776    /// prints the id beside the name, because without it a rename is
777    /// indistinguishable from a new project.
778    ///
779    /// `delete` removes the **registry entry only**. The issues that referenced
780    /// the project keep their references, which then read as diagnosable missing
781    /// references (`tix lint` names each one), so nothing is destroyed by deleting
782    /// a project and there is deliberately no `--cascade` and no bulk-delete of
783    /// issues.
784    ///
785    /// `add`, `remove` and `bulk-add` process each issue selector
786    /// **independently**: a rejection on one selector is reported and never aborts
787    /// the rest. Their scope is project association only — general bulk editing is
788    /// a different feature.
789    Project {
790        #[command(subcommand)]
791        command: ProjectCommand,
792    },
793    /// Create, edit, list, inspect, navigate and delete epics; associate work items
794    ///
795    /// An epic is a stable id (a registry key in `tix.toml`) with a mutable
796    /// display name. A work item references epic **ids**, so a rename breaks no
797    /// reference: the id is the identity and the name is a label. Every listing
798    /// prints the id beside the name, because without it a rename is
799    /// indistinguishable from a new epic.
800    ///
801    /// A "task" is an ordinary issue: `tix edit <sel> --labels task --add-epic
802    /// <id>` adds a reference to an issue. No task entity, file or frontmatter key
803    /// is created, and direct issue navigation stays available from every view.
804    ///
805    /// `delete` removes the **registry entry only**. The issues that referenced the
806    /// epic keep their references (which then read as missing references) and the
807    /// child epics keep their recorded parent (which then reads as unresolved), so
808    /// nothing is destroyed by deleting an epic and there is deliberately no
809    /// `--cascade`.
810    ///
811    /// `add`, `remove` and `bulk-add` process each issue selector
812    /// **independently**: a rejection on one selector is reported and never aborts
813    /// the rest. Their scope is epic reference only — general bulk editing is a
814    /// different feature.
815    Epic {
816        #[command(subcommand)]
817        command: EpicCommand,
818    },
819    /// Create, edit, list, inspect, delete and associate roadmap initiatives
820    ///
821    /// An initiative is a stable id (a registry key in `tix.toml`) with a mutable
822    /// display name, an optional target date/status/owner, and explicit
823    /// project/epic/milestone associations. The id is the identity, so a rename
824    /// breaks no association. Every listing prints the id, because without it a
825    /// rename is indistinguishable from a new initiative.
826    ///
827    /// `delete` removes the **registry entry only**. The record's own references are
828    /// reported and nothing cascades: no project, epic, milestone or issue is
829    /// touched, and no association is silently repaired.
830    ///
831    /// `associate`/`dereference` name exactly one of `--project`, `--epic` or
832    /// `--milestone`; a well-formed reference that names nothing is stored and stays
833    /// diagnosable (`tix roadmap` and `tix lint` report it), and a repeated
834    /// association is an idempotent no-op.
835    Initiative {
836        #[command(subcommand)]
837        command: InitiativeCommand,
838    },
839    /// List and inspect the roadmap: initiatives ordered by target date
840    ///
841    /// Read-only. Each row carries the target date (or the explicit `undated`
842    /// state), the source-attributed summary — one contribution per referenced
843    /// project/epic/milestone with its own value and shared policy version — and the
844    /// ordered diagnostics, so a missing reference is visible on the row and an
845    /// empty summary is explicit. No figure is blended across sources.
846    Roadmap {
847        #[command(subcommand)]
848        command: RoadmapCommand,
849    },
850    /// Read the burndown and scope analytics of a cycle or milestone, or write its
851    /// deterministic chart document
852    ///
853    /// Read-only. `burndown` prints the daily remaining/completed series the shared fold
854    /// produces, `projection` the read-only end-date forecast at the caller's `--as-of`,
855    /// and `export` the versioned local chart document (WP04's target, called by this
856    /// verb rather than re-serialized). The period comes from the cycle/milestone's own
857    /// planning data and `--from`/`--to` replace the bound they name; count and estimate
858    /// modes are explicit and an estimate without a compatible unit is refused rather
859    /// than guessed. A value the record cannot support prints as `-` and never as a zero,
860    /// a day with no recorded history is explicit about its coverage, and nothing here
861    /// mutates an issue, a cycle or a snapshot. The remote `gitlab`/`linear` export
862    /// namespaces are a different command (`tix export`) and are never consulted.
863    Analytics {
864        #[command(subcommand)]
865        command: AnalyticsCommand,
866    },
867    /// Export local issues to an external tracker via glab/linear-cli, or write
868    /// them into a beads workspace via `bd`
869    Export {
870        /// Target tracker: gitlab | linear | ics | beads
871        #[arg(long, value_name = "gitlab|linear|ics|beads")]
872        target: String,
873        /// Print what would change without invoking the remote tracker
874        #[arg(long)]
875        dry_run: bool,
876        /// Override the configured project path (gitlab) or team (linear)
877        #[arg(long)]
878        project: Option<String>,
879        /// Cycle to export for ICS output
880        #[arg(long)]
881        cycle: Option<String>,
882        /// Output path for ICS export
883        #[arg(long)]
884        output: Option<String>,
885        /// List the per-issue diagnostics — which local fields beads cannot hold
886        ///
887        /// Off by default: a board of a few thousand issues produces hundreds of
888        /// them, and a run that sends three issues should not rehearse the notes
889        /// for the other thousand. The counts are always reported either way.
890        #[arg(long)]
891        verbose: bool,
892    },
893    /// Import open issues from an external tracker via glab/linear-cli, mirror a
894    /// local GitHub Spec Kit project, or pull a beads workspace through `bd`
895    Import {
896        /// Target: gitlab | linear (remote trackers) | speckit (local mirror) |
897        /// beads (a beads workspace, through `bd`)
898        #[arg(long, value_name = "gitlab|linear|speckit|beads")]
899        target: String,
900        /// Where an imported issue lands in the local triage inbox (WP07)
901        ///
902        /// `entry` routes into `[triage].entry_status` and refuses a project with
903        /// no `[triage]` section instead of quietly falling back; `remote` keeps
904        /// the column the remote mapping named. Absent is the documented default:
905        /// route when the project has an inbox, otherwise leave the mapping alone.
906        /// Either way the report names the mapping that was used.
907        #[arg(long, value_name = "entry|remote")]
908        triage_policy: Option<String>,
909    },
910    /// Roll open issues forward from expired cycles to the next successor
911    Rollover {
912        /// Restrict rollover to one source cycle
913        #[arg(long)]
914        cycle: Option<String>,
915        /// Skip interactive confirmation
916        #[arg(long)]
917        yes: bool,
918    },
919    /// Search issue files for a string (case-insensitive substring over full markdown)
920    Search {
921        /// Case-insensitive substring to match against the full markdown of every issue
922        #[arg(value_name = "STRING", value_parser = NonEmptyStringValueParser::new())]
923        string: String,
924        /// Include archived issues in the search corpus
925        #[arg(long)]
926        with_archived: bool,
927    },
928    /// Render the current board once and exit
929    Render,
930    /// Prune the centralized backups and remove the scattered ones
931    ///
932    /// Backups live in `.tix-backups/` beside the issues directory (#1581);
933    /// `--keep` (default 10) bounds how many are kept per issue. A legacy
934    /// `*.tixbak` or `*.md.bak` from an older release, still beside an issue, is
935    /// removed too — by default each such file is confirmed interactively.
936    Clean {
937        /// Remove every legacy scattered backup without prompting
938        #[arg(short, long)]
939        force: bool,
940        /// Backups to keep per issue in `.tix-backups/`
941        #[arg(long, default_value_t = 10)]
942        keep: usize,
943    },
944    /// Open the TUI with the create-issue form already open
945    ///
946    /// The session `tix` starts, with the modal that `n` opens already in front of
947    /// you: one command to file an issue, instead of "start the board, then press
948    /// n". It is the interactive twin of `tix create`, which never opens the TUI.
949    New,
950    /// Validate frontmatter option values in all issue files
951    ///
952    /// Every finding renders as a rustc-style diagnostic on stderr: the file and
953    /// line, the offending value, and a `help:` line naming the fix. An **error**
954    /// sets the exit code (a broken file); a **warning** does not (a valid file
955    /// that is worth a look) — so a store with warnings still exits `0`.
956    ///
957    /// What is checked:
958    ///
959    /// - per issue: `status` against the board's columns; `priority`, `title`,
960    ///   `type`, `created`/`updated`, `estimate`; the planning fields `cycles`,
961    ///   `milestones`, `start_date`, `due_date`; a `## History` section that is
962    ///   not first in the body (fenced code examples are skipped) or an entry the
963    ///   grammar cannot read; and unrecognised top-level keys (a warning, since
964    ///   the key is preserved on write);
965    /// - the typed relationships: self-links, missing or conflicting targets,
966    ///   cycles, an unknown kind, and the retired `links:` key;
967    /// - the `parent:` hierarchy: `self_parent`, `unresolved_parent`,
968    ///   `unknown_parent`, `cycle`;
969    /// - `related_issues` entries: blank, or a local selector that resolves to
970    ///   nothing (paths and URLs are accepted verbatim);
971    /// - the configuration: board column/category mapping, `[layout]` drift, and
972    ///   a `status:` that contradicts the issue's own recorded History.
973    ///
974    /// Graph identity findings (`ambiguous_target`, `duplicate_identity`,
975    /// `missing_target`, `unresolved_parent`) are repaired by pointing the
976    /// reference at one canonical issue id. Run `tix migrate issue-graph` to
977    /// preview conversion of legacy numeric endpoints.
978    ///
979    /// Exit: `0` clean or warnings only, `1` any error, `2` usage or an internal
980    /// failure.
981    Lint {
982        /// Do not lint the foreign repositories reachable from `related_issues`
983        /// for this run (overrides `lint_foreign_repositories` in tix.toml).
984        #[arg(long)]
985        no_foreign: bool,
986    },
987    /// Inspect or migrate the configured on-disk issue layout (`[layout]`)
988    Layout {
989        #[command(subcommand)]
990        action: LayoutAction,
991    },
992    /// Migrate local issue data to the canonical issue graph
993    Migrate {
994        #[command(subcommand)]
995        action: MigrateAction,
996    },
997    /// Serve the MCP protocol (JSON-RPC 2.0 over stdio) for agent tools
998    Mcp,
999    /// Manage stable-ID issue/form templates
1000    ///
1001    /// Templates live in `[issue_templates.<id>]` in `tix.toml`, keyed by an
1002    /// immutable stable ID with a mutable display name and an explicit scope.
1003    /// `list`/`show` are read-only; `create`/`update`/`duplicate`/`delete`
1004    /// persist through one atomic save, and a refused operation writes nothing.
1005    Template {
1006        #[command(subcommand)]
1007        action: TemplateAction,
1008    },
1009}
1010
1011/// Process entry: route a parsed [`Cli`] through config resolution, store
1012/// opening and dispatch, then return the exit code for `main` to exit with.
1013#[allow(clippy::too_many_lines)]
1014pub fn run(cli: Cli) -> u8 {
1015    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1016    match &cli.command {
1017        Some(Command::Lint { no_foreign }) => run_lint(&cwd, *no_foreign),
1018        Some(Command::Mcp) => match run_mcp(&cwd) {
1019            Ok(()) => ExitCode::Success as u8,
1020            Err(e) => {
1021                eprintln!("{}", crate::error::render_error(&e));
1022                exit_code_for(&e) as u8
1023            }
1024        },
1025        Some(Command::Render) => match run_render(&cwd) {
1026            Ok(()) => ExitCode::Success as u8,
1027            Err(e) => {
1028                eprintln!("{}", crate::error::render_error(&e));
1029                exit_code_for(&e) as u8
1030            }
1031        },
1032        // A batch move is the second command whose **status** is a result rather
1033        // than an error: it applies each item independently and exits non-zero
1034        // when any item was refused. Routed here, like `tix bulk`, where the exit
1035        // code is decided.
1036        Some(Command::Move {
1037            args,
1038            select,
1039            filter,
1040            search,
1041            dry_run,
1042            commit,
1043            reason,
1044            yes,
1045            r#override,
1046        }) => {
1047            let result = resolve(&cwd).and_then(|(config, store)| {
1048                commands::mv::run_batch(
1049                    &config,
1050                    &store,
1051                    args,
1052                    select.as_deref(),
1053                    filter.as_deref(),
1054                    search.as_deref(),
1055                    commit.as_deref(),
1056                    reason.as_deref(),
1057                    *yes,
1058                    *r#override,
1059                    *dry_run,
1060                )
1061            });
1062            match result {
1063                Ok(code) => code as u8,
1064                Err(e) => {
1065                    eprintln!("{}", crate::error::render_error(&e));
1066                    exit_code_for(&e) as u8
1067                }
1068            }
1069        }
1070        // A bulk batch is the one command whose **status** is a result rather than an
1071        // error: a partial batch completes and reports non-zero, with every outcome
1072        // retained (`contracts/cli-surface.md`). So it is routed here, where the exit
1073        // code is decided, and not through `commands::dispatch`, whose arms are all
1074        // `Result<()>`.
1075        Some(Command::Bulk { action }) => {
1076            let result = resolve(&cwd).and_then(|(_, store)| {
1077                commands::bulk::run(
1078                    &store,
1079                    action,
1080                    action.selection(),
1081                    if action.is_apply() {
1082                        commands::bulk::Mode::Apply
1083                    } else {
1084                        commands::bulk::Mode::Preview
1085                    },
1086                )
1087            });
1088            match result {
1089                Ok(code) => code as u8,
1090                Err(e) => {
1091                    eprintln!("{}", crate::error::render_error(&e));
1092                    exit_code_for(&e) as u8
1093                }
1094            }
1095        }
1096        // `tix triage` shares bulk's exit-code shape for the same reason: a batch
1097        // that partly succeeded is a *completed* command with a status, not an
1098        // error, so `--apply` can print one outcome per selected issue and still
1099        // exit non-zero (T026). It is routed here rather than through
1100        // `commands::dispatch`, whose arms are all `Result<()>`.
1101        Some(Command::Triage { action }) => {
1102            let result = resolve(&cwd).and_then(|(_, store)| commands::triage::run(&store, action));
1103            match result {
1104                Ok(code) => code as u8,
1105                Err(e) => {
1106                    eprintln!("{}", crate::error::render_error(&e));
1107                    exit_code_for(&e) as u8
1108                }
1109            }
1110        }
1111        // `tix archive`/`tix restore` share the batch exit-code shape: every
1112        // selector is processed independently, a failure on one does not roll back
1113        // another, and the process still exits non-zero if any failed. Routed here
1114        // for the same reason `tix bulk` and `tix triage` are.
1115        Some(Command::Archive { issues }) => {
1116            route_archive(&cwd, commands::archive::Action::Archive, issues)
1117        }
1118        Some(Command::Restore { issues }) => {
1119            route_archive(&cwd, commands::archive::Action::Restore, issues)
1120        }
1121        // `tix project` needs a **mutable** `Config` — an accepted create/edit/delete
1122        // *is* `tix.toml` changing — which `commands::dispatch` (whose arms all borrow
1123        // `&Config`) cannot express, exactly as `tix wip` and `tix view <subcommand>`
1124        // do. Its batch verbs (`add`, `remove`, `bulk-add`) also carry a **status**
1125        // rather than an error: each selector is independent and a partly-successful
1126        // run exits non-zero. Both properties are why it is routed here, where the
1127        // config is owned and the exit code is decided.
1128        Some(Command::Project { command }) => {
1129            let result = resolve(&cwd).and_then(|(mut config, store)| {
1130                commands::project::run(&mut config, &store, command)
1131            });
1132            match result {
1133                Ok(code) => code as u8,
1134                Err(e) => {
1135                    eprintln!("{}", crate::error::render_error(&e));
1136                    exit_code_for(&e) as u8
1137                }
1138            }
1139        }
1140        // `tix epic` shares `tix project`'s two properties exactly: its lifecycle
1141        // verbs (`create`/`edit`/`delete`) mutate `tix.toml`, so it needs the
1142        // **mutable** `Config` only `run` owns, and its batch verbs
1143        // (`add`/`remove`/`bulk-add`) carry a **status** rather than an error,
1144        // because each selector is independent and a partly-successful run exits
1145        // non-zero. Both are why it is routed here.
1146        Some(Command::Epic { command }) => {
1147            let result = resolve(&cwd)
1148                .and_then(|(mut config, store)| commands::epic::run(&mut config, &store, command));
1149            match result {
1150                Ok(code) => code as u8,
1151                Err(e) => {
1152                    eprintln!("{}", crate::error::render_error(&e));
1153                    exit_code_for(&e) as u8
1154                }
1155            }
1156        }
1157        // `tix initiative` shares `tix project`'s property: its lifecycle verbs
1158        // (`create`/`edit`/`delete`) and its association verbs mutate `tix.toml`, so
1159        // it needs the **mutable** `Config` only `run` owns, and an accepted change
1160        // is written through the registry's atomic boundary. `tix roadmap` is
1161        // read-only and fits `commands::dispatch`, so it is routed there.
1162        Some(Command::Initiative { command }) => {
1163            let result = resolve(&cwd).and_then(|(mut config, store)| {
1164                commands::roadmap::run_initiative(&mut config, &store, command)
1165            });
1166            match result {
1167                Ok(code) => code as u8,
1168                Err(e) => {
1169                    eprintln!("{}", crate::error::render_error(&e));
1170                    exit_code_for(&e) as u8
1171                }
1172            }
1173        }
1174        // Only `tix cycle schedule …` needs the **mutable** config: an accepted
1175        // `schedule create|edit|delete` *is* `tix.toml` changing, and
1176        // `commands::dispatch` (whose arms all borrow `&Config`) cannot provide it.
1177        // The frozen legacy verbs are deliberately **not** intercepted: they fall
1178        // through to `run_subcommand`, so they keep `commands::dispatch`'s legacy
1179        // arm *and* the rollover advisory that every `tix cycle …` emitted at the
1180        // baseline — behavior WP06 must not change.
1181        Some(Command::Cycle {
1182            command: CycleCommand::Schedule { command },
1183        }) => {
1184            let result = resolve(&cwd).and_then(|(mut config, store)| {
1185                commands::cycle::run_schedule(&mut config, &store, command)
1186            });
1187            match result {
1188                Ok(()) => ExitCode::Success as u8,
1189                Err(e) => {
1190                    eprintln!("{}", crate::error::render_error(&e));
1191                    exit_code_for(&e) as u8
1192                }
1193            }
1194        }
1195        // `tix capacity set|delete` mutate `tix.toml`, so they need the **mutable**
1196        // `Config` only the process entry owns — the same reason `tix wip` is routed
1197        // here. The read verbs (`list`/`show`/`calculate`/`snapshot`) ride along so
1198        // the family has one boundary, and none of them writes.
1199        Some(Command::Capacity { command }) => {
1200            let result = resolve(&cwd).and_then(|(mut config, store)| {
1201                commands::capacity::run(&mut config, &store, command)
1202            });
1203            match result {
1204                Ok(()) => ExitCode::Success as u8,
1205                Err(e) => {
1206                    eprintln!("{}", crate::error::render_error(&e));
1207                    exit_code_for(&e) as u8
1208                }
1209            }
1210        }
1211        Some(Command::Export {
1212            target,
1213            dry_run,
1214            project,
1215            cycle,
1216            output,
1217            verbose,
1218        }) => {
1219            let result = resolve(&cwd).and_then(|(config, store)| {
1220                commands::export::run(
1221                    &config,
1222                    &store,
1223                    target,
1224                    *dry_run,
1225                    project.as_deref(),
1226                    cycle.as_deref(),
1227                    output.as_deref(),
1228                    *verbose,
1229                )
1230            });
1231            match result {
1232                Ok(code) => code as u8,
1233                Err(e) => {
1234                    eprintln!("{}", crate::error::render_error(&e));
1235                    exit_code_for(&e) as u8
1236                }
1237            }
1238        }
1239        // `tix template`: management mutates `tix.toml`, so it needs a **mutable**
1240        // config and the path it came from — neither of which `commands::dispatch`
1241        // carries. Routed here, before the store is opened, exactly like the TUI
1242        // and `tix mcp`.
1243        Some(Command::Template { action }) => match commands::templates::run(&cwd, action) {
1244            Ok(()) => ExitCode::Success as u8,
1245            Err(e) => {
1246                eprintln!("{}", crate::error::render_error(&e));
1247                exit_code_for(&e) as u8
1248            }
1249        },
1250        // `tix version` needs neither a store nor a config, so it is answered
1251        // before any repository resolution: it works in a directory where every
1252        // other command would refuse to start, which is exactly where someone
1253        // checking "did this build?" may be standing.
1254        Some(Command::Version) => {
1255            commands::version::print();
1256            ExitCode::Success as u8
1257        }
1258        Some(Command::Beads { action }) => {
1259            let result = resolve(&cwd)
1260                .and_then(|(config, store)| commands::beads::run(&config, &store, action));
1261            match result {
1262                Ok(()) => ExitCode::Success as u8,
1263                Err(e) => {
1264                    eprintln!("{}", crate::error::render_error(&e));
1265                    exit_code_for(&e) as u8
1266                }
1267            }
1268        }
1269        _ => {
1270            let result = match &cli.command {
1271                None => run_tui(&cwd),
1272                Some(Command::New) => run_tui_new(&cwd),
1273                Some(Command::Init { beads }) => commands::init::run_with(&cwd, beads.as_deref()),
1274                Some(Command::InitAgents { yes }) => {
1275                    let root = Config::project_root(&cwd).unwrap_or(cwd.clone());
1276                    commands::agents::run(&root, *yes).map(|_| ())
1277                }
1278                Some(cmd) => run_subcommand(&cwd, cmd),
1279            };
1280            match result {
1281                Ok(()) => ExitCode::Success as u8,
1282                Err(e) => {
1283                    eprintln!("{}", crate::error::render_error(&e));
1284                    exit_code_for(&e) as u8
1285                }
1286            }
1287        }
1288    }
1289}
1290
1291/// Process one `tix archive`/`tix restore` batch and map it to an exit code.
1292///
1293/// Each selector is independent: an outcome is printed per item and a failure on
1294/// one item neither aborts the rest nor rolls back a successful rename. The
1295/// process exits with the first failure's code, or `0` when every item succeeded.
1296fn route_archive(cwd: &Path, action: commands::archive::Action, issues: &[String]) -> u8 {
1297    match resolve(cwd).and_then(|(_, store)| commands::archive::run(&store, action, issues)) {
1298        Ok(code) => code as u8,
1299        Err(e) => {
1300            eprintln!("{}", crate::error::render_error(&e));
1301            exit_code_for(&e) as u8
1302        }
1303    }
1304}
1305
1306/// `tix lint` owns its own boundary: its exit-code table (`0` healthy, `1`
1307/// findings, `2` usage/internal) does not match the generic
1308/// [`exit_code_for`] mapping, which would collapse internal errors into `1`.
1309fn run_lint(cwd: &Path, no_foreign: bool) -> u8 {
1310    let (config, store) = match resolve(cwd) {
1311        Ok(pair) => pair,
1312        Err(e) => {
1313            eprintln!("{}", crate::error::render_error(&e));
1314            return 2;
1315        }
1316    };
1317    match commands::lint::run_with(&config, &store, !no_foreign) {
1318        Ok(true) => 1,
1319        Ok(false) => 0,
1320        Err(e) => {
1321            eprintln!("{}", crate::error::render_error(&e));
1322            2
1323        }
1324    }
1325}
1326
1327/// `tix` with no subcommand: launch the TUI, or fail with the contract's
1328/// exact message when no `tix.toml` is reachable (exit 1).
1329///
1330/// tix #1632: on a terminal, a missing `tix.toml` is offered a one-key escape —
1331/// create the default configuration and continue into the TUI — rather than
1332/// being turned away with a message naming a command to run. A declined prompt,
1333/// and a stdin that cannot be asked (not a terminal), both keep the exact
1334/// contract message.
1335fn run_tui(cwd: &Path) -> Result<()> {
1336    let config = tui_config(cwd)?;
1337    crate::app::run(&config)
1338}
1339
1340/// `tix new`: the same session, opened on the create-issue form instead of the
1341/// board. Same repository resolution as [`run_tui`], so a missing `tix.toml`
1342/// fails with the same message rather than opening a form that could not be
1343/// submitted.
1344fn run_tui_new(cwd: &Path) -> Result<()> {
1345    let config = tui_config(cwd)?;
1346    crate::app::run_new_issue(&config)
1347}
1348
1349/// Repository resolution for the TUI commands, offering to scaffold a repository
1350/// first when none is reachable (tix #1632).
1351fn tui_config(cwd: &Path) -> Result<Config> {
1352    if !offer_first_run_init(cwd)? {
1353        return Err(TixError::Config(
1354            "no tix.toml found — run \"tix init\" first".into(),
1355        ));
1356    }
1357    repo_config(cwd)
1358}
1359
1360/// Ask, once, whether a missing `tix.toml` should be created with the defaults.
1361///
1362/// Returns `true` when the caller may proceed (a repository was already there, or
1363/// the user said yes to `tix init` default values). Returns `false` when there is
1364/// no repository and the user declined — or could not be asked at all, since a
1365/// non-terminal stdin cannot consent, and silently writing a project into a pipe's
1366/// directory would be the wrong answer.
1367fn offer_first_run_init(cwd: &Path) -> Result<bool> {
1368    use std::io::IsTerminal;
1369
1370    if Config::project_root(cwd).is_some() {
1371        return Ok(true);
1372    }
1373    if !std::io::stdin().is_terminal() {
1374        return Ok(false);
1375    }
1376    eprint!("tix: no tix.toml found — create one with default settings? [Y/n] ");
1377    let mut line = String::new();
1378    std::io::stdin()
1379        .read_line(&mut line)
1380        .map_err(TixError::Io)?;
1381    let answer = line.trim();
1382    if answer.is_empty() || answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") {
1383        commands::init::run_with(cwd, Some("off"))?;
1384        return Ok(true);
1385    }
1386    eprintln!("tix: aborted — run \"tix init\" when you are ready");
1387    Ok(false)
1388}
1389
1390/// Strict repository resolution shared by the TUI and `tix mcp` (FR-015):
1391/// upward `tix.toml` discovery, failing with the contract's exact `no
1392/// tix.toml found` guidance (exit 1) when no repository is reachable —
1393/// never a guessed default store. This is the "CLI hot path" that
1394/// `contracts/mcp-protocol.md` §7 requires `tix mcp` to reuse.
1395fn repo_config(cwd: &Path) -> Result<Config> {
1396    let root = Config::project_root(cwd)
1397        .ok_or_else(|| TixError::Config("no tix.toml found — run \"tix init\" first".into()))?;
1398    let mut config = Config::load(&root)?;
1399    // `tags_dir`, `[repos]` and `parent_repo` are relative to the project root
1400    // (tix #1529/#1530), so the TUI can spawn tix in a child from anywhere.
1401    config.resolve_repo_paths(&root);
1402    Ok(config)
1403}
1404
1405/// `tix mcp`: strict repository resolution (same hot path as `tix` with no
1406/// subcommand), then serve the MCP protocol synchronously over stdio. Outside
1407/// a repository the process fails with exit 1 BEFORE reading any protocol
1408/// bytes (FR-016, `contracts/mcp-protocol.md` §7).
1409fn run_mcp(cwd: &Path) -> Result<()> {
1410    let config = repo_config(cwd)?;
1411    let store = Store::open(&config)?;
1412    // Absolutize milestones against the same project root as issues_dir so the
1413    // MCP `create_issue`/`update_issue` planning validators and the CLI resolve
1414    // milestone files to the identical directory (parity cross-checker).
1415    let mut config = config;
1416    if config.milestones_dir().is_relative() {
1417        let root = Config::project_root(cwd).unwrap_or_else(|| cwd.to_path_buf());
1418        let configured = config.milestones_dir();
1419        config.milestones_dir = Some(root.join(configured));
1420    }
1421    crate::mcp::serve(&config, &store)
1422}
1423
1424/// `tix render`: strict repository resolution like `tix`/`tix mcp` so the
1425/// command fails with the same no-config guidance outside a repository.
1426fn run_render(cwd: &Path) -> Result<()> {
1427    let config = repo_config(cwd)?;
1428    let store = Store::open(&config)?;
1429    commands::dispatch(&Command::Render, &config, &store)
1430}
1431
1432/// Config resolution for subcommands: defaults when no `tix.toml` exists
1433/// (relative to the project root via upward walk), with a one-time stderr
1434/// notice. Only explicit per-command requirements fail hard.
1435fn resolve(cwd: &Path) -> Result<(Config, Store)> {
1436    let root = Config::project_root(cwd);
1437    let mut config = match &root {
1438        Some(r) => Config::load(r)?,
1439        None => Config::default(),
1440    };
1441    // Resolve a relative issues_dir against the project root (or cwd when
1442    // unconfigured) so subcommands behave identically from any subdirectory.
1443    if config.issues_dir.is_relative() {
1444        let base = root.as_deref().unwrap_or(cwd);
1445        config.issues_dir = base.join(&config.issues_dir);
1446    }
1447    // Milestones resolve against the same base as issues_dir: the planning
1448    // validators (create/edit) and `lint` must agree on where milestone
1449    // registry files live regardless of the invocation directory.
1450    if config.milestones_dir().is_relative() {
1451        let base = root.as_deref().unwrap_or(cwd);
1452        let configured = config.milestones_dir();
1453        config.milestones_dir = Some(base.join(configured));
1454    }
1455    // `tags_dir`, `[repos]` and `parent_repo` resolve against the same base.
1456    config.resolve_repo_paths(root.as_deref().unwrap_or(cwd));
1457    if root.is_none() {
1458        eprintln!("tix: no tix.toml found (run 'tix init' to create one)");
1459    }
1460    let store = Store::open(&config)?;
1461    Ok((config, store))
1462}
1463
1464fn run_subcommand(cwd: &Path, cmd: &Command) -> Result<()> {
1465    let (mut config, store) = resolve(cwd)?;
1466    // `tix view <subcommand>` is the one command tree that needs the config
1467    // **mutably**: an accepted view mutation *is* `config.views` changing, and
1468    // `commands::dispatch` (whose every arm borrows `&Config`) cannot express
1469    // that. Routed here, at the process boundary where the config is owned, for
1470    // the same reason `tix bulk` and `tix triage` are routed in `run`.
1471    if let Command::View {
1472        action: Some(action),
1473        ..
1474    } = cmd
1475    {
1476        return commands::views::run(&mut config, &store, action);
1477    }
1478    // `tix wip set/unset/unit` mutate `tix.toml`, so they are routed here for the
1479    // same reason the view mutations are: `commands::dispatch`'s arms borrow
1480    // `&Config`, and an accepted WIP edit *is* the configuration changing.
1481    if let Command::Wip { action } = cmd {
1482        return commands::wip::run(&mut config, &store, action);
1483    }
1484    if !matches!(cmd, Command::Rollover { .. }) {
1485        let planner = crate::planning::Planner::from_config(config.clone(), cwd.to_path_buf());
1486        let plan = planner.rollover_plan(&store, &crate::model::today());
1487        if let Some(first) = plan.first() {
1488            eprintln!(
1489                "hint: rollover of {} issue(s) from \"{}\" to \"{}\" is suggested; run 'tix rollover' to apply",
1490                first.issues.len(),
1491                first.from_cycle,
1492                first.to_cycle
1493            );
1494        }
1495    }
1496    commands::dispatch(cmd, &config, &store)
1497}