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