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}