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 /// Projects to associate the new issue with: ids or names, comma-separated
98 ///
99 /// Every reference is resolved through the project service **before** the
100 /// issue is written, so an unknown id refuses the whole create rather than
101 /// leaving a half-associated issue. This flag lives on the shared create
102 /// path, which is the handoff point a future template surface (source issue
103 /// #174) would populate (mission `first-class-projects-01M3BYBW`).
104 #[arg(long, value_name = "ID")]
105 projects: Option<String>,
106 /// Epics the new issue references: ids or names, comma-separated
107 ///
108 /// Every reference is resolved through the epic service **before** the issue
109 /// is written, so an unknown id refuses the whole create rather than leaving
110 /// a half-referenced issue. This flag lives on the shared create path, which
111 /// is the handoff point a future template surface (source issue #174) would
112 /// populate. Referencing an epic never changes how the issue itself is
113 /// navigated: it stays an ordinary issue (mission `epics-work-items-01M3BYW4`).
114 #[arg(long, value_name = "ID")]
115 epics: Option<String>,
116 /// Reviewer (free-form name/identity)
117 #[arg(long)]
118 reviewer: Option<String>,
119 /// Start date (`YYYY-MM-DD`)
120 #[arg(long)]
121 start_date: Option<String>,
122 /// Due date (`YYYY-MM-DD`); must be `>= start_date`
123 #[arg(long)]
124 due_date: Option<String>,
125 /// Create as a child of this issue (number, filename or title fragment)
126 ///
127 /// At most one parent, so this takes a single selector and there is no list
128 /// form (C-001). The parent must already exist; a selector that matches no
129 /// issue is refused and nothing is created.
130 #[arg(long, value_name = "SELECTOR")]
131 parent: Option<String>,
132 /// Related issues, comma-separated: a local number/selector, a relative
133 /// or absolute path to another issue file, or a repository URL
134 /// (`host/path#id`). Free-form; stored verbatim.
135 #[arg(long)]
136 related_issues: Option<String>,
137 /// Issue type (e.g. `bug`, `feature`); `tix lint` warns on a value not in
138 /// `custom_types`.
139 #[arg(long)]
140 r#type: Option<String>,
141 /// Create from a stable template ID (`tix template list`)
142 ///
143 /// The template's defaults and prompts are merged with the flags above
144 /// by the pure resolver, then the resolved request goes through the
145 /// shared create core — there is no second issue writer. Resolution
146 /// never prompts: a required prompt with no value is a diagnostic, the
147 /// template and the store are left untouched, and no issue is written.
148 #[arg(long, value_name = "STABLE-ID")]
149 template: Option<String>,
150 /// Template scope: `repository` | `board:<name>` | `team:<label>`
151 ///
152 /// Only meaningful with `--template` (clap requires it). An omitted
153 /// scope is the explicit `repository` default, never an implicit
154 /// inheritance. The selected template ID and scope are echoed on
155 /// stderr so stdout keeps the `Created <file>` shape.
156 #[arg(long, value_name = "SCOPE", requires = "template")]
157 scope: Option<String>,
158 },
159 /// Create, edit, remove, or list typed relationships
160 ///
161 /// Creating requires `--type`; there is no default kind, so an untyped
162 /// relationship is never recorded by accident (C-001). Omit the
163 /// subcommand to create: `tix link <from> <to> --type <kind>`.
164 Link {
165 /// `edit`, `remove`, or `list`. Omit to create a relationship.
166 ///
167 /// These three words are reserved on `tix link`: an issue whose number,
168 /// filename, or title selector is exactly `edit`, `remove`, or `list`
169 /// must be addressed by number.
170 #[command(subcommand)]
171 action: Option<LinkAction>,
172 /// [create] Issue number, filename fragment or unique title fragment
173 from: Option<String>,
174 /// [create] Issue number, filename fragment or unique title fragment
175 to: Option<String>,
176 /// [create] Relationship kind: `blocks`, `blocked_by`, `duplicates`, `related`
177 #[arg(long, value_name = "KIND")]
178 r#type: Option<String>,
179 },
180 /// Batch editing over a selection
181 ///
182 /// Preview is the **default**; `--apply` writes. Name exactly one of `--select`,
183 /// `--filter` or `--search` — they are different grammars, and a bulk operation
184 /// will not guess which issues you meant.
185 Bulk {
186 #[command(subcommand)]
187 action: BulkAction,
188 },
189 /// Remove a typed relationship between two issues
190 Unlink {
191 /// Issue number, filename fragment or unique title fragment
192 from: String,
193 /// Issue number, filename fragment or unique title fragment
194 to: String,
195 /// Relationship kind: `blocks`, `blocked_by`, `duplicates`, `related`
196 #[arg(long, value_name = "KIND")]
197 r#type: Option<String>,
198 },
199 /// Print the transitive dependency chain an issue holds up
200 Chain {
201 /// Issue number, filename fragment or unique title fragment
202 issue: String,
203 },
204 /// Inspect the issue hierarchy
205 ///
206 /// `show`, `children`, or `list`. Every figure comes from the hierarchy
207 /// service's typed values, so the CLI, the TUI detail block and the MCP read
208 /// tools report the same numbers for the same issue.
209 Parent {
210 /// `show`, `children`, or `list`
211 #[command(subcommand)]
212 action: ParentAction,
213 },
214 /// Edit an issue
215 Edit {
216 /// Issue number, filename fragment or unique title fragment
217 issue: String,
218 /// New title
219 #[arg(long)]
220 title: Option<String>,
221 /// New body (markdown)
222 #[arg(long)]
223 body: Option<String>,
224 /// New status
225 ///
226 /// Setting the status is a **move**, validated by the same service as
227 /// `tix mv` and recorded as the same `TransitionDecision` — it is not a
228 /// way around the transition policy. `--status ""` clears the field
229 /// (matching every other field here) and is a plain field write, not a
230 /// transition, because there is no target column to judge.
231 #[arg(long)]
232 status: Option<String>,
233 /// New priority: low | medium | high
234 #[arg(long)]
235 priority: Option<String>,
236 /// New assignee (@name)
237 #[arg(long)]
238 assignee: Option<String>,
239 /// New label list, comma-separated
240 #[arg(long)]
241 labels: Option<String>,
242 /// New files list, comma-separated
243 #[arg(long)]
244 files: Option<String>,
245 /// New estimate
246 #[arg(long)]
247 estimate: Option<String>,
248 /// New cycle list, comma-separated (`--cycles ""` clears)
249 #[arg(long)]
250 cycles: Option<String>,
251 /// New milestone list, comma-separated (`--milestones ""` clears)
252 #[arg(long)]
253 milestones: Option<String>,
254 /// Associate the issue with a project (id or name); repeatable
255 ///
256 /// Idempotent: an already-present association is a no-op with no History
257 /// entry. The reference is resolved through the project service before
258 /// anything is written, so an unknown project refuses the edit and leaves
259 /// the file byte-identical.
260 #[arg(long, value_name = "ID")]
261 add_project: Vec<String>,
262 /// Dissociate the issue from a project (id or name); repeatable
263 ///
264 /// Drops only the association — the issue is never deleted. A known project
265 /// that is not associated is a no-op; an unresolvable reference that the
266 /// issue does not carry is refused as `unknown_project`.
267 #[arg(long, value_name = "ID")]
268 remove_project: Vec<String>,
269 /// Replace the whole association list, comma-separated
270 ///
271 /// Only a blank value is accepted here: `--projects ""` clears every
272 /// association, mirroring the empty-string-clears convention the other list
273 /// flags follow. To add or drop individual associations use
274 /// `--add-project` / `--remove-project`; a non-blank value is refused rather
275 /// than guessed at.
276 #[arg(long, value_name = "IDS")]
277 projects: Option<String>,
278 /// Add an epic reference (id or name); repeatable
279 ///
280 /// Idempotent: an already-present reference is a no-op with no History
281 /// entry. The reference is resolved through the epic service before
282 /// anything is written, so an unknown epic refuses the edit and leaves the
283 /// file byte-identical.
284 #[arg(long = "add-epic", value_name = "ID")]
285 add_epic: Vec<String>,
286 /// Remove an epic reference; repeatable
287 ///
288 /// Drops only the reference — the issue is never deleted. A known epic that
289 /// is not referenced is a no-op; an unresolvable reference the issue does not
290 /// carry is refused as `unknown_epic`. A retained stale id can still be
291 /// removed by naming it verbatim.
292 #[arg(long = "remove-epic", value_name = "ID")]
293 remove_epic: Vec<String>,
294 /// Clear every epic reference
295 ///
296 /// Only a blank value is accepted here: `--epics ""` clears every reference,
297 /// mirroring the empty-string-clears convention `--projects` follows. To add
298 /// or drop individual references use `--add-epic` / `--remove-epic`; a
299 /// non-blank value is refused rather than guessed at.
300 #[arg(long, value_name = "IDS")]
301 epics: Option<String>,
302 /// New reviewer (`--reviewer ""` clears)
303 #[arg(long)]
304 reviewer: Option<String>,
305 /// New start date (`YYYY-MM-DD`; `--start-date ""` clears)
306 #[arg(long)]
307 start_date: Option<String>,
308 /// New due date (`YYYY-MM-DD`; `--due-date ""` clears)
309 #[arg(long)]
310 due_date: Option<String>,
311 /// Commit hash to record (`auto` resolves it from git history, `clear` removes it)
312 #[arg(long)]
313 commit: Option<String>,
314 /// Set or change the parent (number, filename or title fragment)
315 ///
316 /// `--parent ""` clears the parent, mirroring `--assignee ""`; omitting the
317 /// flag leaves it untouched. Validated before any write, so a self-parent,
318 /// an unknown parent, or a change that would close a cycle leaves the file
319 /// byte-identical.
320 #[arg(long, value_name = "SELECTOR")]
321 parent: Option<String>,
322 /// Why a transition policy is being overridden by `--status`
323 ///
324 /// `--status` is validated exactly as `tix mv` is, so moving an issue out
325 /// of a terminal category needs the same reasoned override, and the reason
326 /// is recorded in the same History decision. `--status` deliberately has
327 /// no `--yes`: acknowledging blockers is spelled `tix move … --yes`, and
328 /// giving this flag a second spelling would give the audit trail two.
329 #[arg(long, value_name = "TEXT")]
330 reason: Option<String>,
331 /// New related-issues list, comma-separated (`--related-issues ""` clears).
332 /// A local number/selector, a relative or absolute path to another issue
333 /// file, or a repository URL (`host/path#id`).
334 #[arg(long)]
335 related_issues: Option<String>,
336 /// New issue type (`--type ""` clears it); `tix lint` warns on a value not
337 /// in `custom_types`.
338 #[arg(long)]
339 r#type: Option<String>,
340 },
341 /// Move an issue to a column
342 Move {
343 /// Issue number, filename fragment or unique title fragment
344 issue: String,
345 /// Target column
346 column: String,
347 /// Commit hash to record when moving to a completion column (`auto`
348 /// resolves it from git history; omitted, the current HEAD is captured
349 /// automatically)
350 ///
351 /// "Completion" is whatever the board's category mapping calls
352 /// `completed` — not a column literally named `done`. On a board with
353 /// no `[kanban.<name>.category_map]` the board's `done_column` is the
354 /// completion column.
355 #[arg(long, value_name = "HASH")]
356 commit: Option<String>,
357 /// Why a transition policy is being overridden
358 ///
359 /// Required to move an issue *out of* a terminal category (`completed`
360 /// or `canceled`) when the board declares a `category_map`. The reason
361 /// is recorded in the issue's History, so the audit trail explains
362 /// itself later. A blank value is not a reason and is refused.
363 ///
364 /// A board with no `category_map` has no transition policy to override:
365 /// it keeps the completion definition and the commit rule, and moving
366 /// out of `done` stays permitted.
367 #[arg(long, value_name = "TEXT")]
368 reason: Option<String>,
369 /// Confirm without prompting
370 ///
371 /// Also the acknowledgement a move toward the board's completion column
372 /// needs when the issue has open blockers. Without it that move is
373 /// refused, and a recorded acknowledgement is appended to the issue's
374 /// History so the decision stays auditable.
375 #[arg(long)]
376 yes: bool,
377 /// Authorize overriding a **blocking work-in-progress limit**
378 ///
379 /// `--override` is a request, not an authorization. The move only proceeds
380 /// when `--reason` states why and `--yes` confirms it, and the override is
381 /// recorded — actor, timestamp, reason and the limit it was authorized
382 /// against — as a History entry on the issue. Without `--override` a move
383 /// that would exceed a `block` limit is refused and nothing is written; a
384 /// `warn` limit never needs one.
385 #[arg(long)]
386 r#override: bool,
387 },
388 /// Inspect or configure work-in-progress limits
389 ///
390 /// Limits live in `tix.toml` under `[kanban.<board>.wip]`: a column's ceilings
391 /// (`max_items`, `max_estimate`), the unit an estimate ceiling counts in, and
392 /// whether exceeding it `warn`s or `block`s. A board with no limits behaves
393 /// exactly as it did before they existed.
394 Wip {
395 #[command(subcommand)]
396 action: WipAction,
397 },
398 /// Print an issue, or manage saved views (#172)
399 ///
400 /// `tix view <issue>` prints the issue file, unchanged. Without an issue
401 /// selector the subcommands below manage repository-owned **saved views**:
402 /// named, stable-ID query definitions with explicit scope metadata and a
403 /// normalized plan over the existing filter/sort/group/visibility grammar.
404 ///
405 /// The nine subcommand words (`list`, `show`, `create`, `update`,
406 /// `duplicate`, `delete`, `apply`, `status`, `share`) are **reserved** on
407 /// `tix view`: an issue whose selector is exactly one of them must be
408 /// addressed by number, exactly as `tix link` reserves `edit` / `remove` /
409 /// `list`.
410 #[command(visible_alias = "show")]
411 View {
412 /// `list`, `show`, `create`, `update`, `duplicate`, `delete`, `apply`,
413 /// `status`, or `share`. Omit to print an issue.
414 #[command(subcommand)]
415 action: Option<ViewAction>,
416 /// [print] Issue number, filename fragment or unique title fragment
417 issue: Option<String>,
418 /// [print] Extra information to append, comma-separated
419 ///
420 /// `relationships` appends the service-derived relationship block, the
421 /// transitive chain, and the blocked reason. Without it the issue file
422 /// is printed verbatim, so `tix view <n> > file` still round-trips.
423 #[arg(long, value_name = "relationships")]
424 show: Option<String>,
425 },
426 /// Manage recurring issue series (tix #175)
427 ///
428 /// A series is a **normal issue** that carries a `series:` table in its
429 /// frontmatter: the issue holds the rule, the lifecycle state, and the
430 /// template an occurrence is created from; each occurrence is another normal
431 /// issue carrying the series id, the date and the idempotency key.
432 ///
433 /// `detect` is read-only and reports what is due; `generate` and every
434 /// lifecycle action need `--yes`, because no refresh, timer or detection may
435 /// change a schedule on its own.
436 Recurring {
437 #[command(subcommand)]
438 action: RecurringAction,
439 },
440 /// Table of issues
441 List {
442 /// Only issues in this status
443 #[arg(long)]
444 status: Option<String>,
445 /// Token query (`cycle:`, `milestone:`, `reviewer:`, `overdue`, etc.)
446 #[arg(long)]
447 filter: Option<String>,
448 /// Restrict the listing to one project (stable id, or a display name)
449 ///
450 /// Resolved through the project service and applied through the shared
451 /// `project:` criterion, so `tix list --project <id>` and
452 /// `tix search "project:<id>"` return the same matching set.
453 #[arg(long)]
454 project: Option<String>,
455 /// Restrict the listing to one epic (stable id, or a display name)
456 ///
457 /// Resolved through the epic service and applied through the shared
458 /// `epic:` criterion, so `tix list --epic <id>` and
459 /// `tix search "epic:<id>"` return the same matching set. An unknown
460 /// reference matches nothing, so a typo yields a visible empty listing
461 /// rather than a silent unfiltered one.
462 #[arg(long)]
463 epic: Option<String>,
464 /// Include archived issues, marked with an ARCHIVED column
465 ///
466 /// Off by default, and off for a reason: archived issues are skipped
467 /// without being read, so a repository with thousands of them lists fast.
468 #[arg(long)]
469 with_archived: bool,
470 /// Sort key (`created`, `due`, `start`, `reviewer`, `cycle`, `milestone`, `project`, `epic`, `status`, `priority`)
471 #[arg(long)]
472 sort: Option<String>,
473 /// Group key (`cycle` | `milestone` | `project` | `epic`)
474 #[arg(long)]
475 group: Option<String>,
476 /// Comma-separated columns to display
477 ///
478 /// The full set: `num`, `title`, `status`, `priority`, `cycle`, `milestone`,
479 /// `reviewer`, `start`, `due`, `overdue`, `blocked`, `relations`, `progress`,
480 /// `category`, `archived`, `wip`, `health`. The `value_name` used to list only
481 /// the first ten, so a column this build had supported for two missions
482 /// looked unsupported in `--help`.
483 #[arg(long, value_name = "COLUMNS")]
484 columns: Option<String>,
485 /// Extra information to include, comma-separated
486 ///
487 /// `relationships` adds a relationship summary and a blocked marker
488 /// per issue, read from the relationship service so the CLI and the
489 /// TUI cannot disagree.
490 ///
491 /// `progress` adds a parent rollup; `category` adds the issue's resolved
492 /// category label, read from the shared service so this surface and the
493 /// TUI cannot spell one category two ways.
494 ///
495 /// `wip` adds the column's usage against its limit, and `health` adds the
496 /// issue's health (its own value, with the derived rollup beside it when the
497 /// two differ), read from the one portable health document.
498 #[arg(long, value_name = "SHOW")]
499 show: Option<String>,
500 },
501 /// Inspect the status-category vocabulary, a board's mapping, and the
502 /// effective transition policy
503 ///
504 /// A read-only surface. It never writes and never repairs, so it is safe to run
505 /// on a repository whose `tix.toml` is mid-edit.
506 Workflow {
507 #[command(subcommand)]
508 action: WorkflowAction,
509 },
510 /// Board summary: count per column + total
511 Status {
512 /// Token query (`cycle:`, `milestone:`, `reviewer:`, `overdue`, etc.)
513 #[arg(long)]
514 filter: Option<String>,
515 /// Sort key (`created`, `due`, `start`, `reviewer`, `cycle`, `milestone`, `status`, `priority`)
516 #[arg(long)]
517 sort: Option<String>,
518 /// Group key (`cycle` | `milestone` | `project` | `epic`)
519 #[arg(long)]
520 group: Option<String>,
521 /// Add the health summary: issue counts per state, with diagnostics
522 ///
523 /// Read from the same portable health document `tix health board` and the
524 /// TUI render, so the figures are the same figures. Off by default: the board
525 /// summary is about columns, and health is metadata about the work (C-006).
526 #[arg(long)]
527 health: bool,
528 },
529 /// Project and issue health: read a scope, roll it up, or record a change
530 ///
531 /// Health is **metadata**, not the board's status: it never replaces a status
532 /// category and never means "done". The current value lives in an issue's
533 /// frontmatter, every change is an append-only History entry carrying an actor, a
534 /// date and a rationale, and a scope's derived value comes from what contributes
535 /// to it under a versioned policy (`health-v1`).
536 ///
537 /// `set` and `override` are the mutations, and both refuse — having written
538 /// nothing — when an audit field is missing. `show`, `rollup`, `history` and
539 /// `board` read.
540 Health {
541 #[command(subcommand)]
542 action: HealthAction,
543 },
544 /// Read and act on your `@mentions` (issue-mentions)
545 ///
546 /// A mention is `@handle` or `@name@host` in an issue's title or body, recorded
547 /// per mentioned target under `docs/mentions/`. This command is the
548 /// **non-interactive** surface: it lists what is pending for you and applies the
549 /// three answers without a prompt, so the flow works from a script and without a
550 /// TTY. The TUI has the same three answers on the bottom indicator row's chord.
551 Mentions {
552 #[command(subcommand)]
553 action: MentionsAction,
554 },
555 /// Triage the inbox: read-only queue views and the four decisions
556 Triage {
557 #[command(subcommand)]
558 action: TriageAction,
559 },
560 /// Delete an issue file
561 Delete {
562 /// Issue number, filename fragment or unique title fragment
563 issue: String,
564 /// Confirm without prompting
565 #[arg(long)]
566 yes: bool,
567 },
568 /// Archive issue files: rename each in place to a dot-prefixed name
569 ///
570 /// The default views (`tix list`, `tix search`) skip archived issues; pass
571 /// their `--with-archived` flag or the `archived:true` filter to see them.
572 Archive {
573 /// Issue numbers, filename fragments or unique title fragments
574 #[arg(required = true, value_name = "ISSUE")]
575 issues: Vec<String>,
576 },
577 /// Restore archived issue files: the inverse of `tix archive`
578 Restore {
579 /// Issue numbers, filename fragments or unique title fragments
580 #[arg(required = true, value_name = "ISSUE")]
581 issues: Vec<String>,
582 },
583 /// Manage planning cycles
584 Cycle {
585 #[command(subcommand)]
586 command: CycleCommand,
587 },
588 /// Configure and inspect cycle, team and assignee capacity
589 ///
590 /// A ceiling is a scope (one cycle, one team label, or one assignee identity)
591 /// with a non-negative value in an estimate unit the active board declares.
592 /// `calculate` reports the shared committed/completed/remaining/overflow/
593 /// utilization values and never mutates an issue; `snapshot` reads back the
594 /// immutable evidence a cycle close captured.
595 Capacity {
596 #[command(subcommand)]
597 command: CapacityCommand,
598 },
599 /// Record and review actual effort against issues, in whole minutes
600 ///
601 /// A time entry is a duration, an optional `YYYY-MM-DD` calendar date, an author
602 /// and an optional note — there is no timer, no timezone conversion and no overlap
603 /// rule. Current entries live in the issue's own frontmatter, every correction and
604 /// deletion appends an attributable History event, and a deletion removes the
605 /// current value only. `add`, `edit` and `delete` are the mutations (`edit` and
606 /// `delete` require `--reason`; `delete` also requires `--confirm`), and `show`,
607 /// `history` and `total` are reads whose numbers come from one shared calculation.
608 Time {
609 #[command(subcommand)]
610 command: TimeCommand,
611 },
612 /// Manage milestones
613 Milestone {
614 #[command(subcommand)]
615 command: MilestoneCommand,
616 },
617 /// Create, edit, list, inspect, delete and populate projects
618 ///
619 /// A project is a stable id (a registry key in `tix.toml`) with a mutable
620 /// display name. An issue carries **project ids**, so a rename breaks no
621 /// association: the id is the identity and the name is a label. Every listing
622 /// prints the id beside the name, because without it a rename is
623 /// indistinguishable from a new project.
624 ///
625 /// `delete` removes the **registry entry only**. The issues that referenced
626 /// the project keep their references, which then read as diagnosable missing
627 /// references (`tix lint` names each one), so nothing is destroyed by deleting
628 /// a project and there is deliberately no `--cascade` and no bulk-delete of
629 /// issues.
630 ///
631 /// `add`, `remove` and `bulk-add` process each issue selector
632 /// **independently**: a rejection on one selector is reported and never aborts
633 /// the rest. Their scope is project association only — general bulk editing is
634 /// a different feature.
635 Project {
636 #[command(subcommand)]
637 command: ProjectCommand,
638 },
639 /// Create, edit, list, inspect, navigate and delete epics; associate work items
640 ///
641 /// An epic is a stable id (a registry key in `tix.toml`) with a mutable
642 /// display name. A work item references epic **ids**, so a rename breaks no
643 /// reference: the id is the identity and the name is a label. Every listing
644 /// prints the id beside the name, because without it a rename is
645 /// indistinguishable from a new epic.
646 ///
647 /// A "task" is an ordinary issue: `tix edit <sel> --labels task --add-epic
648 /// <id>` adds a reference to an issue. No task entity, file or frontmatter key
649 /// is created, and direct issue navigation stays available from every view.
650 ///
651 /// `delete` removes the **registry entry only**. The issues that referenced the
652 /// epic keep their references (which then read as missing references) and the
653 /// child epics keep their recorded parent (which then reads as unresolved), so
654 /// nothing is destroyed by deleting an epic and there is deliberately no
655 /// `--cascade`.
656 ///
657 /// `add`, `remove` and `bulk-add` process each issue selector
658 /// **independently**: a rejection on one selector is reported and never aborts
659 /// the rest. Their scope is epic reference only — general bulk editing is a
660 /// different feature.
661 Epic {
662 #[command(subcommand)]
663 command: EpicCommand,
664 },
665 /// Create, edit, list, inspect, delete and associate roadmap initiatives
666 ///
667 /// An initiative is a stable id (a registry key in `tix.toml`) with a mutable
668 /// display name, an optional target date/status/owner, and explicit
669 /// project/epic/milestone associations. The id is the identity, so a rename
670 /// breaks no association. Every listing prints the id, because without it a
671 /// rename is indistinguishable from a new initiative.
672 ///
673 /// `delete` removes the **registry entry only**. The record's own references are
674 /// reported and nothing cascades: no project, epic, milestone or issue is
675 /// touched, and no association is silently repaired.
676 ///
677 /// `associate`/`dereference` name exactly one of `--project`, `--epic` or
678 /// `--milestone`; a well-formed reference that names nothing is stored and stays
679 /// diagnosable (`tix roadmap` and `tix lint` report it), and a repeated
680 /// association is an idempotent no-op.
681 Initiative {
682 #[command(subcommand)]
683 command: InitiativeCommand,
684 },
685 /// List and inspect the roadmap: initiatives ordered by target date
686 ///
687 /// Read-only. Each row carries the target date (or the explicit `undated`
688 /// state), the source-attributed summary — one contribution per referenced
689 /// project/epic/milestone with its own value and shared policy version — and the
690 /// ordered diagnostics, so a missing reference is visible on the row and an
691 /// empty summary is explicit. No figure is blended across sources.
692 Roadmap {
693 #[command(subcommand)]
694 command: RoadmapCommand,
695 },
696 /// Read the burndown and scope analytics of a cycle or milestone, or write its
697 /// deterministic chart document
698 ///
699 /// Read-only. `burndown` prints the daily remaining/completed series the shared fold
700 /// produces, `projection` the read-only end-date forecast at the caller's `--as-of`,
701 /// and `export` the versioned local chart document (WP04's target, called by this
702 /// verb rather than re-serialized). The period comes from the cycle/milestone's own
703 /// planning data and `--from`/`--to` replace the bound they name; count and estimate
704 /// modes are explicit and an estimate without a compatible unit is refused rather
705 /// than guessed. A value the record cannot support prints as `-` and never as a zero,
706 /// a day with no recorded history is explicit about its coverage, and nothing here
707 /// mutates an issue, a cycle or a snapshot. The remote `gitlab`/`linear` export
708 /// namespaces are a different command (`tix export`) and are never consulted.
709 Analytics {
710 #[command(subcommand)]
711 command: AnalyticsCommand,
712 },
713 /// Export local issues to an external tracker via glab/linear-cli
714 Export {
715 /// Target tracker: gitlab | linear
716 #[arg(long, value_name = "gitlab|linear|ics")]
717 target: String,
718 /// Print what would change without invoking the remote tracker
719 #[arg(long)]
720 dry_run: bool,
721 /// Override the configured project path (gitlab) or team (linear)
722 #[arg(long)]
723 project: Option<String>,
724 /// Cycle to export for ICS output
725 #[arg(long)]
726 cycle: Option<String>,
727 /// Output path for ICS export
728 #[arg(long)]
729 output: Option<String>,
730 },
731 /// Import open issues from an external tracker via glab/linear-cli, or
732 /// mirror a local GitHub Spec Kit project
733 Import {
734 /// Target: gitlab | linear (remote trackers) | speckit (local mirror)
735 #[arg(long, value_name = "gitlab|linear|speckit")]
736 target: String,
737 /// Where an imported issue lands in the local triage inbox (WP07)
738 ///
739 /// `entry` routes into `[triage].entry_status` and refuses a project with
740 /// no `[triage]` section instead of quietly falling back; `remote` keeps
741 /// the column the remote mapping named. Absent is the documented default:
742 /// route when the project has an inbox, otherwise leave the mapping alone.
743 /// Either way the report names the mapping that was used.
744 #[arg(long, value_name = "entry|remote")]
745 triage_policy: Option<String>,
746 },
747 /// Roll open issues forward from expired cycles to the next successor
748 Rollover {
749 /// Restrict rollover to one source cycle
750 #[arg(long)]
751 cycle: Option<String>,
752 /// Skip interactive confirmation
753 #[arg(long)]
754 yes: bool,
755 },
756 /// Search issue files for a string (case-insensitive substring over full markdown)
757 Search {
758 /// Case-insensitive substring to match against the full markdown of every issue
759 #[arg(value_name = "STRING", value_parser = NonEmptyStringValueParser::new())]
760 string: String,
761 /// Include archived issues in the search corpus
762 #[arg(long)]
763 with_archived: bool,
764 },
765 /// Render the current board once and exit
766 Render,
767 /// Prune the centralized backups and remove the scattered ones
768 ///
769 /// Backups live in `.tix-backups/` beside the issues directory (#1581);
770 /// `--keep` (default 10) bounds how many are kept per issue. A legacy
771 /// `*.tixbak` or `*.md.bak` from an older release, still beside an issue, is
772 /// removed too — by default each such file is confirmed interactively.
773 Clean {
774 /// Remove every legacy scattered backup without prompting
775 #[arg(short, long)]
776 force: bool,
777 /// Backups to keep per issue in `.tix-backups/`
778 #[arg(long, default_value_t = 10)]
779 keep: usize,
780 },
781 /// Open the TUI with the create-issue form already open
782 ///
783 /// The session `tix` starts, with the modal that `n` opens already in front of
784 /// you: one command to file an issue, instead of "start the board, then press
785 /// n". It is the interactive twin of `tix create`, which never opens the TUI.
786 New,
787 /// Validate frontmatter option values in all issue files
788 ///
789 /// Every finding renders as a rustc-style diagnostic on stderr: the file and
790 /// line, the offending value, and a `help:` line naming the fix. An **error**
791 /// sets the exit code (a broken file); a **warning** does not (a valid file
792 /// that is worth a look) — so a store with warnings still exits `0`.
793 ///
794 /// What is checked:
795 ///
796 /// - per issue: `status` against the board's columns; `priority`, `title`,
797 /// `type`, `created`/`updated`, `estimate`; the planning fields `cycles`,
798 /// `milestones`, `start_date`, `due_date`; a `## History` section that is
799 /// not first in the body (fenced code examples are skipped) or an entry the
800 /// grammar cannot read; and unrecognised top-level keys (a warning, since
801 /// the key is preserved on write);
802 /// - the typed relationships: self-links, missing or conflicting targets,
803 /// cycles, an unknown kind, and the retired `links:` key;
804 /// - the `parent:` hierarchy: `self_parent`, `unresolved_parent`,
805 /// `unknown_parent`, `cycle`;
806 /// - `related_issues` entries: blank, or a local selector that resolves to
807 /// nothing (paths and URLs are accepted verbatim);
808 /// - the configuration: board column/category mapping, `[layout]` drift, and
809 /// a `status:` that contradicts the issue's own recorded History.
810 ///
811 /// Exit: `0` clean or warnings only, `1` any error, `2` usage or an internal
812 /// failure.
813 Lint {
814 /// Do not lint the foreign repositories reachable from `related_issues`
815 /// for this run (overrides `lint_foreign_repositories` in tix.toml).
816 #[arg(long)]
817 no_foreign: bool,
818 },
819 /// Inspect or migrate the configured on-disk issue layout (`[layout]`)
820 Layout {
821 #[command(subcommand)]
822 action: LayoutAction,
823 },
824 /// Serve the MCP protocol (JSON-RPC 2.0 over stdio) for agent tools
825 Mcp,
826 /// Manage stable-ID issue/form templates
827 ///
828 /// Templates live in `[issue_templates.<id>]` in `tix.toml`, keyed by an
829 /// immutable stable ID with a mutable display name and an explicit scope.
830 /// `list`/`show` are read-only; `create`/`update`/`duplicate`/`delete`
831 /// persist through one atomic save, and a refused operation writes nothing.
832 Template {
833 #[command(subcommand)]
834 action: TemplateAction,
835 },
836}
837
838/// The `tix template` management tree (`contracts/cli-surface.md`).
839///
840/// `list`/`show` are read-only; the four mutations call one service that
841/// validates the complete candidate registry before a single atomic
842/// `tix.toml` save, so a refused operation leaves the file byte-identical.
843/// Identity is the immutable stable ID: `update` changes a display name
844/// without touching scope, defaults, prompts, references, or preserved
845/// unknown metadata.
846#[derive(Subcommand)]
847pub enum TemplateAction {
848 /// List templates: one `id<TAB>name<TAB>scope` row each, in declaration order
849 List,
850 /// Show one template by stable ID (or a unique display name)
851 Show {
852 /// Stable ID, or a display name that names exactly one template
853 reference: String,
854 },
855 /// Create a template under a new stable ID
856 Create {
857 /// The new stable ID: ASCII letters, digits, `-` or `_`
858 #[arg(long, value_name = "ID")]
859 id: String,
860 /// Display name; omitted means the ID is shown in its place
861 #[arg(long, value_name = "NAME")]
862 name: Option<String>,
863 /// Scope: `repository` (default) | `board:<name>` | `team:<label>`
864 #[arg(long, value_name = "SCOPE")]
865 scope: Option<String>,
866 /// A normal create default, `FIELD=VALUE` (repeatable). Supplying any
867 /// `--default` replaces the whole defaults block.
868 #[arg(long = "default", value_name = "FIELD=VALUE")]
869 defaults: Vec<String>,
870 /// A structured prompt,
871 /// `key=<field>[,kind=text|select|multi-select|date][,required=true|false][,default=<v>][,label=<l>][,options_ref=<source>]`
872 /// (repeatable). Supplying any `--prompt` replaces the whole prompt list.
873 #[arg(long = "prompt", value_name = "KEY,ATTR=VALUE...")]
874 prompts: Vec<String>,
875 /// A typed reference,
876 /// `kind=label|assignee|file|cycle|milestone|relationship,value=<v>[,required=true|false]`
877 /// (repeatable). Supplying any `--reference` replaces the whole list.
878 #[arg(long = "reference", value_name = "KIND=VALUE,ATTR=VALUE...")]
879 references: Vec<String>,
880 },
881 /// Update the flags supplied, leaving every other stored field as it was
882 Update {
883 /// Stable ID, or a display name that names exactly one template
884 reference: String,
885 /// New display name (the stable ID never changes)
886 #[arg(long, value_name = "NAME")]
887 name: Option<String>,
888 /// New scope: `repository` | `board:<name>` | `team:<label>`
889 #[arg(long, value_name = "SCOPE")]
890 scope: Option<String>,
891 /// Replacement defaults, `FIELD=VALUE` (repeatable)
892 #[arg(long = "default", value_name = "FIELD=VALUE")]
893 defaults: Vec<String>,
894 /// Replacement prompts (repeatable; same grammar as `create`)
895 #[arg(long = "prompt", value_name = "KEY,ATTR=VALUE...")]
896 prompts: Vec<String>,
897 /// Replacement references (repeatable; same grammar as `create`)
898 #[arg(long = "reference", value_name = "KIND=VALUE,ATTR=VALUE...")]
899 references: Vec<String>,
900 },
901 /// Copy a template to a new stable ID, leaving the source untouched
902 Duplicate {
903 /// Stable ID, or a display name that names exactly one template
904 reference: String,
905 /// The new stable ID; omitted allocates `<source>-copy`, `-copy-2`, …
906 #[arg(long, value_name = "ID")]
907 id: Option<String>,
908 /// Display name for the copy; omitted keeps the source's label
909 #[arg(long, value_name = "NAME")]
910 name: Option<String>,
911 },
912 /// Delete a template definition (never the issues created from it)
913 Delete {
914 /// Stable ID, or a display name that names exactly one template
915 reference: String,
916 /// Confirm without prompting
917 #[arg(long)]
918 yes: bool,
919 },
920}
921
922/// The `tix triage` tree (`contracts/cli-surface.md`, WP05 T023–T025).
923///
924/// `list` and `status` are read-only queue views; the four decision verbs take a
925/// single issue; `batch` is the selection-shaped twin of `tix bulk`, preview by
926/// default and writing only under an explicit `--apply`.
927///
928/// ## Why the reason flag is `Option` on `accept` and the rest
929///
930/// `contracts/decision-history.md` splits it: decline, duplicate and defer
931/// *require* a reason, accept records one when supplied. A required clap argument
932/// on three of the four would make the fourth's absence look like an oversight,
933/// and — more importantly — the enforcement belongs to the service, which refuses
934/// `missing_reason` before any write on every surface (NFR-003). The flag is
935/// therefore optional everywhere and the *rule* is the service's, so the CLI,
936/// TUI and MCP cannot end up with three different ideas of which decision needs
937/// one. `tix triage decline 1` with no `--reason` still fails, at the service,
938/// with the contract's own kind.
939/// `tix health`'s actions.
940#[derive(Subcommand)]
941pub enum HealthAction {
942 /// Record an issue's health state (append-only; needs --rationale)
943 ///
944 /// Writes the frontmatter value and **one** History entry, together or not at
945 /// all. Any of the four states is accepted; anything else is refused by name.
946 Set {
947 /// The issue, by number or slug
948 issue: String,
949 /// One of `unknown`, `on_track`, `needs_attention`, `at_risk`
950 #[arg(long)]
951 state: String,
952 /// Who is making the change (defaults to the configured git identity)
953 #[arg(long)]
954 actor: Option<String>,
955 /// When, as `YYYY-MM-DD` (defaults to today)
956 #[arg(long)]
957 date: Option<String>,
958 /// Why — required: a health change nobody can explain is not recorded
959 #[arg(long)]
960 rationale: Option<String>,
961 },
962 /// Show one issue's health: its own value, its derived rollup, its diagnostics
963 Show {
964 /// The issue, by number or slug
965 issue: String,
966 },
967 /// Show a scope's derived health, with its contributors and policy version
968 Rollup {
969 /// `issue` | `project` | `epic` | `cycle` | `milestone`
970 #[arg(long)]
971 scope: String,
972 /// The scope's stable id (an issue number for `--scope issue`)
973 #[arg(long)]
974 id: String,
975 },
976 /// Layer an explicit override over a derived scope's health
977 ///
978 /// An override is a deliberate act about a whole scope, so it needs `--confirm`:
979 /// it does not replace what the children say, it is recorded *over* it.
980 Override {
981 /// `project` | `epic` | `cycle` | `milestone`
982 #[arg(long)]
983 scope: String,
984 /// The scope's stable id
985 #[arg(long)]
986 id: String,
987 /// One of `unknown`, `on_track`, `needs_attention`, `at_risk`
988 #[arg(long)]
989 state: String,
990 /// Who is making the change (defaults to the configured git identity)
991 #[arg(long)]
992 actor: Option<String>,
993 /// When, as `YYYY-MM-DD` (defaults to today)
994 #[arg(long)]
995 date: Option<String>,
996 /// Why — required
997 #[arg(long)]
998 rationale: Option<String>,
999 /// Confirm an override of derived health
1000 #[arg(long)]
1001 confirm: bool,
1002 },
1003 /// The append-only health History entries about a scope
1004 History {
1005 /// A scope: `issue:42`, `cycle:2026-10`, `milestone:m1`, …
1006 scope: String,
1007 },
1008 /// Every scope's health, with what contributes to each
1009 Board,
1010}
1011
1012#[derive(Subcommand)]
1013pub enum TriageAction {
1014 /// Read the inbox queue: one row per entry (FR-001)
1015 List {
1016 /// Sort by entry age, oldest first; an unknown age sorts last
1017 #[arg(long)]
1018 age: bool,
1019 /// Print the counts only, with no entries
1020 #[arg(long)]
1021 count: bool,
1022 },
1023 /// Read the inbox summary: decision counts and the age histogram (FR-005)
1024 Status,
1025 /// Accept: move into the normal workflow, validated by #183's transition policy
1026 Accept {
1027 /// Issue number, filename fragment or unique title fragment
1028 issue: String,
1029 /// Reason recorded in History; accept records one when supplied
1030 #[arg(long)]
1031 reason: Option<String>,
1032 /// Target column; defaults to `[triage].accepted_status`
1033 #[arg(long, value_name = "COLUMN")]
1034 to: Option<String>,
1035 /// Who decided; defaults to the git identity
1036 #[arg(long)]
1037 actor: Option<String>,
1038 },
1039 /// Decline: move to `[triage].declined_status`; never deletes or archives
1040 Decline {
1041 /// Issue number, filename fragment or unique title fragment
1042 issue: String,
1043 /// Why it is declined (required)
1044 #[arg(long)]
1045 reason: Option<String>,
1046 /// Who decided; defaults to the git identity
1047 #[arg(long)]
1048 actor: Option<String>,
1049 },
1050 /// Duplicate: record a typed `duplicates` relation; never merges files
1051 Duplicate {
1052 /// Issue number, filename fragment or unique title fragment
1053 issue: String,
1054 /// The issue this one duplicates
1055 #[arg(long, value_name = "ISSUE")]
1056 of: String,
1057 /// Why it is a duplicate (required)
1058 #[arg(long)]
1059 reason: Option<String>,
1060 /// Who decided; defaults to the git identity
1061 #[arg(long)]
1062 actor: Option<String>,
1063 },
1064 /// Defer: stay in the inbox with a refreshed entry time
1065 Defer {
1066 /// Issue number, filename fragment or unique title fragment
1067 issue: String,
1068 /// Why it is deferred (required)
1069 #[arg(long)]
1070 reason: Option<String>,
1071 /// Who decided; defaults to the git identity
1072 #[arg(long)]
1073 actor: Option<String>,
1074 },
1075 /// One decision across a selection: preview by default, `--apply` to write
1076 Batch {
1077 /// Comma-separated selectors: number, filename or title fragment
1078 #[arg(long, value_name = "LIST")]
1079 select: Option<String>,
1080 /// A `tix list --filter` query selecting the issues
1081 #[arg(long, value_name = "QUERY")]
1082 filter: Option<String>,
1083 /// Which decision to make: accept | decline | duplicate | defer
1084 #[arg(long, value_name = "KIND")]
1085 decision: String,
1086 /// Reason recorded on every applied event; required for three of four
1087 #[arg(long)]
1088 reason: Option<String>,
1089 /// Who decided; defaults to the git identity
1090 #[arg(long)]
1091 actor: Option<String>,
1092 /// The duplicate target selector, for `--decision duplicate`
1093 #[arg(long, value_name = "ISSUE")]
1094 of: Option<String>,
1095 /// The accept target column, for `--decision accept`
1096 #[arg(long, value_name = "COLUMN")]
1097 to: Option<String>,
1098 /// Apply the previewed plan; without it nothing is written
1099 #[arg(long)]
1100 apply: bool,
1101 },
1102}
1103
1104#[derive(Subcommand)]
1105pub enum CycleCommand {
1106 Create {
1107 #[arg(long)]
1108 name: Option<String>,
1109 #[arg(long)]
1110 description: Option<String>,
1111 #[arg(long = "start-date")]
1112 start_date: Option<String>,
1113 #[arg(long = "due-date")]
1114 due_date: Option<String>,
1115 },
1116 List,
1117 Edit {
1118 #[arg(long)]
1119 name: String,
1120 #[arg(long = "new-name")]
1121 new_name: Option<String>,
1122 #[arg(long)]
1123 description: Option<String>,
1124 #[arg(long = "start-date")]
1125 start_date: Option<String>,
1126 #[arg(long = "due-date")]
1127 due_date: Option<String>,
1128 },
1129 Delete {
1130 #[arg(long)]
1131 name: String,
1132 #[arg(long)]
1133 yes: bool,
1134 },
1135 /// Manage repeating cycle schedules (`[cycle_schedules.<id>]`)
1136 ///
1137 /// A schedule is a stable id (the registry key) with a mutable display name;
1138 /// generated cycle instances are ordinary cycle records this family creates
1139 /// idempotently from the schedule's definition. Detection is read-only; every
1140 /// mutation requires `--confirm`.
1141 Schedule {
1142 #[command(subcommand)]
1143 command: CycleScheduleCommand,
1144 },
1145}
1146
1147/// The `tix cycle schedule` subcommands (mission
1148/// `repeating-cycle-schedules-01M3C7W1`, WP06).
1149///
1150/// The surface contract's spelling (`contracts/cli-surface.md`): a schedule
1151/// namespace under `tix cycle`, with stable-ID selectors, explicit date/timezone
1152/// context and confirmed mutations. This enum is a thin adapter — the schedule
1153/// record, the read model, evaluation, generation and the lifecycle all live in
1154/// `crate::cycle_schedule`; nothing here re-derives a cadence, a cursor or a
1155/// History entry.
1156#[derive(Debug, Clone, Subcommand)]
1157pub enum CycleScheduleCommand {
1158 /// List schedules, one tab-separated row per schedule
1159 ///
1160 /// Columns: `id`, `name`, `state`, `cadence`, `duration`, `start_date`,
1161 /// `look_ahead`, `next_sequence`, `generated`.
1162 List,
1163 /// Show one schedule: identity, cadence, duration, cursor and its instances
1164 Show {
1165 /// Stable schedule id, or its unique display name
1166 #[arg(value_name = "SCHEDULE")]
1167 schedule: String,
1168 },
1169 /// Define a new repeating schedule; validated before persistence, requires `--confirm`
1170 Create(CycleScheduleCreateArgs),
1171 /// Change a schedule's fields, keeping its stable id; requires `--confirm`
1172 ///
1173 /// Only the flags you name change; the rest of the record (including its
1174 /// generation cursor and preserved metadata) is carried over.
1175 Edit {
1176 /// Stable schedule id, or its unique display name
1177 #[arg(value_name = "SCHEDULE")]
1178 schedule: String,
1179 #[command(flatten)]
1180 args: CycleScheduleEditArgs,
1181 /// Confirm the change without prompting
1182 #[arg(long)]
1183 confirm: bool,
1184 },
1185 /// Delete a schedule's registry entry; requires `--confirm`
1186 ///
1187 /// The generated cycles it created and their snapshots are **not** deleted:
1188 /// they keep their schedule reference, which then reads as a diagnosable
1189 /// missing schedule, so nothing is destroyed by deleting a definition.
1190 Delete {
1191 /// Stable schedule id, or its unique display name
1192 #[arg(value_name = "SCHEDULE")]
1193 schedule: String,
1194 /// Confirm the deletion without prompting
1195 #[arg(long)]
1196 confirm: bool,
1197 },
1198 /// Report ended/due/look-ahead warnings, read-only
1199 ///
1200 /// Prints the warnings and the explicit decisions each accepts. It writes
1201 /// nothing: no file, no cursor move, no History entry.
1202 Detect {
1203 /// Evaluate against this `YYYY-MM-DD` date instead of today
1204 #[arg(long, value_name = "YYYY-MM-DD")]
1205 date: Option<String>,
1206 },
1207 /// Create the schedule's future cycles; idempotent, requires `--confirm`
1208 ///
1209 /// A repeated call under the same stable keys is a no-op, never a duplicate.
1210 Generate {
1211 /// Stable schedule id, or its unique display name
1212 #[arg(value_name = "SCHEDULE")]
1213 schedule: String,
1214 /// Generate every candidate whose start date is on or before this date
1215 #[arg(long, value_name = "YYYY-MM-DD")]
1216 through: Option<String>,
1217 /// Confirm the generation without prompting
1218 #[arg(long)]
1219 confirm: bool,
1220 },
1221 /// Close an instance: capture its immutable snapshot and mark it closed
1222 Close {
1223 /// Stable schedule id, or its unique display name
1224 #[arg(value_name = "SCHEDULE")]
1225 schedule: String,
1226 #[command(flatten)]
1227 args: CycleScheduleReasonArgs,
1228 },
1229 /// Reopen a closed instance: restore `active` and append one History event
1230 ///
1231 /// The closed cycle's capacity snapshot is **not** rewritten: it stays the
1232 /// immutable evidence the close captured, and the appended `cycle_reopened`
1233 /// event names it. Reopening an open instance is a no-op, and the action
1234 /// requires both a reason and the instance it names.
1235 Reopen {
1236 /// Stable schedule id, or its unique display name
1237 #[arg(value_name = "SCHEDULE")]
1238 schedule: String,
1239 #[command(flatten)]
1240 args: CycleScheduleReasonArgs,
1241 },
1242 /// Push an instance's due date out by a cadence period
1243 Extend {
1244 /// Stable schedule id, or its unique display name
1245 #[arg(value_name = "SCHEDULE")]
1246 schedule: String,
1247 #[command(flatten)]
1248 args: CycleScheduleExtendArgs,
1249 },
1250 /// Record a sequence as skipped so it is never regenerated
1251 Skip {
1252 /// Stable schedule id, or its unique display name
1253 #[arg(value_name = "SCHEDULE")]
1254 schedule: String,
1255 #[command(flatten)]
1256 args: CycleScheduleReasonArgs,
1257 },
1258 /// Pause a schedule and its non-terminal instances
1259 Pause {
1260 /// Stable schedule id, or its unique display name
1261 #[arg(value_name = "SCHEDULE")]
1262 schedule: String,
1263 #[command(flatten)]
1264 args: CycleScheduleLifecycleArgs,
1265 },
1266 /// Resume a paused schedule and its instances
1267 Resume {
1268 /// Stable schedule id, or its unique display name
1269 #[arg(value_name = "SCHEDULE")]
1270 schedule: String,
1271 #[command(flatten)]
1272 args: CycleScheduleLifecycleArgs,
1273 },
1274 /// Re-derive every future instance's dates from the schedule's definition
1275 AdjustFuture {
1276 /// Stable schedule id, or its unique display name
1277 #[arg(value_name = "SCHEDULE")]
1278 schedule: String,
1279 #[command(flatten)]
1280 args: CycleScheduleReasonArgs,
1281 },
1282 /// Close a warning without deciding anything; nothing is written
1283 Ignore {
1284 /// Stable schedule id, or its unique display name
1285 #[arg(value_name = "SCHEDULE")]
1286 schedule: String,
1287 #[command(flatten)]
1288 args: CycleScheduleLifecycleArgs,
1289 },
1290}
1291
1292/// The flags that define a schedule (`tix cycle schedule create`).
1293#[derive(Debug, Clone, Args)]
1294pub struct CycleScheduleCreateArgs {
1295 /// Stable schedule id (letters, digits, `-`, `_`, `:`); never reused
1296 #[arg(long, value_name = "ID")]
1297 pub id: String,
1298 /// Display label, unique case-insensitively within the registry
1299 #[arg(long, value_name = "NAME")]
1300 pub name: String,
1301 /// Cadence family: `daily`, `weekly`, `monthly` or `interval`
1302 ///
1303 /// `interval` is the family that carries `--unit`; the other three are the
1304 /// calendar-step families and refuse a unit.
1305 #[arg(long, value_name = "KIND")]
1306 pub cadence: String,
1307 /// How many cadence units between cycle starts (1-1000; absent means 1)
1308 #[arg(long, value_name = "N")]
1309 pub interval: Option<u32>,
1310 /// The unit an `interval` cadence counts in: `days`, `weeks`, `months`
1311 #[arg(long, value_name = "UNIT")]
1312 pub unit: Option<String>,
1313 /// How long one cycle lasts, as a positive count
1314 #[arg(long, value_name = "N")]
1315 pub duration: u32,
1316 /// The duration's unit: `days`, `weeks`, `months`
1317 #[arg(long = "duration-unit", value_name = "UNIT")]
1318 pub duration_unit: String,
1319 /// The schedule's stable anchor, a `YYYY-MM-DD` calendar date
1320 #[arg(long = "start-date", value_name = "YYYY-MM-DD")]
1321 pub start_date: String,
1322 /// Fixed UTC offset the schedule is expressed in: `UTC` or `±HH:MM`
1323 ///
1324 /// `allow_hyphen_values` so a west-of-UTC offset (`-05:00`) reaches the
1325 /// validator instead of being swallowed by clap as an unknown flag.
1326 #[arg(long, value_name = "TZ", allow_hyphen_values = true)]
1327 pub timezone: String,
1328 /// How many future cycles to consider beyond the current one (0 when absent)
1329 #[arg(long = "look-ahead", value_name = "N")]
1330 pub look_ahead: Option<u32>,
1331 /// Confirm the creation without prompting
1332 #[arg(long)]
1333 pub confirm: bool,
1334}
1335
1336/// What `tix cycle schedule edit` may change. Every field is optional; only the
1337/// named ones change, so a partial edit keeps the rest of the record.
1338#[derive(Debug, Clone, Args)]
1339pub struct CycleScheduleEditArgs {
1340 /// New display label
1341 #[arg(long, value_name = "NAME")]
1342 pub name: Option<String>,
1343 /// New cadence family: `daily`, `weekly`, `monthly` or `interval`
1344 #[arg(long, value_name = "KIND")]
1345 pub cadence: Option<String>,
1346 /// New cadence interval (1-1000)
1347 #[arg(long, value_name = "N")]
1348 pub interval: Option<u32>,
1349 /// New cadence unit (only meaningful for the `interval` family)
1350 #[arg(long, value_name = "UNIT")]
1351 pub unit: Option<String>,
1352 /// New duration count
1353 #[arg(long, value_name = "N")]
1354 pub duration: Option<u32>,
1355 /// New duration unit
1356 #[arg(long = "duration-unit", value_name = "UNIT")]
1357 pub duration_unit: Option<String>,
1358 /// New anchor date, `YYYY-MM-DD`
1359 #[arg(long = "start-date", value_name = "YYYY-MM-DD")]
1360 pub start_date: Option<String>,
1361 /// New timezone: `UTC` or `±HH:MM` (`allow_hyphen_values` for `-05:00`)
1362 #[arg(long, value_name = "TZ", allow_hyphen_values = true)]
1363 pub timezone: Option<String>,
1364 /// New look-ahead count
1365 #[arg(long = "look-ahead", value_name = "N")]
1366 pub look_ahead: Option<u32>,
1367}
1368
1369/// The flags every `tix cycle schedule` lifecycle action shares.
1370#[derive(Debug, Clone, Args)]
1371pub struct CycleScheduleLifecycleArgs {
1372 /// Target instance, `<schedule-id>:<sequence>` (e.g. `cs-sprint:000001`)
1373 #[arg(long, value_name = "CYCLE")]
1374 pub cycle: Option<String>,
1375 /// Select the instance by its start date, `YYYY-MM-DD`
1376 #[arg(long, value_name = "YYYY-MM-DD")]
1377 pub from: Option<String>,
1378 /// Confirm the action without prompting
1379 #[arg(long)]
1380 pub confirm: bool,
1381}
1382
1383/// The lifecycle flags plus the reason the recording actions require.
1384#[derive(Debug, Clone, Args)]
1385pub struct CycleScheduleReasonArgs {
1386 #[command(flatten)]
1387 pub lifecycle: CycleScheduleLifecycleArgs,
1388 /// Why; recorded in the instance's History
1389 #[arg(long, value_name = "TEXT")]
1390 pub reason: Option<String>,
1391}
1392
1393/// `tix cycle schedule extend`'s flags: the reason plus the period count.
1394#[derive(Debug, Clone, Args)]
1395pub struct CycleScheduleExtendArgs {
1396 #[command(flatten)]
1397 pub reason: CycleScheduleReasonArgs,
1398 /// How many cadence periods to extend by; the shared lifecycle supports 1
1399 #[arg(long, value_name = "N")]
1400 pub by: Option<u32>,
1401}
1402
1403/// The `tix capacity` subcommands (mission `cycle-capacity-planning-01M3C85C`, WP05).
1404///
1405/// A thin adapter: the `[capacity]` section, its validation, the shared calculation,
1406/// the findings and the close evidence all live in `crate::capacity`. This enum owns
1407/// only the spelling of the surface — the scope and the unit are always explicit
1408/// (C-003/C-004), and no flag here can change an issue.
1409#[derive(Debug, Clone, Subcommand)]
1410pub enum CapacityCommand {
1411 /// List every configured ceiling, one tab-separated row per entry
1412 ///
1413 /// Columns: `id`, `scope`, `reference`, `display`, `capacity`, `unit`,
1414 /// `enabled`. The header prints even with no entries, so an empty section is
1415 /// visibly empty rather than indistinguishable from a failure.
1416 List,
1417 /// Show one ceiling by the reference it names
1418 ///
1419 /// A reference is a cycle's name or stable id, a team label, or an assignee
1420 /// identity; `--scope` narrows it when one reference names two scopes.
1421 Show {
1422 /// Cycle name/stable id, team label, or assignee identity
1423 #[arg(value_name = "REFERENCE")]
1424 reference: String,
1425 /// Restrict the match to one scope: `cycle`, `team` or `assignee`
1426 #[arg(long, value_name = "SCOPE")]
1427 scope: Option<String>,
1428 },
1429 /// Set one scope's ceiling; validated before persistence
1430 ///
1431 /// The scope and the unit are explicit: a value with no unit, a unit no board
1432 /// declares, an unknown cycle/team, or a second ceiling for one scope is
1433 /// refused before `tix.toml` changes.
1434 Set {
1435 /// Scope dimension: `cycle`, `team` or `assignee`
1436 #[arg(long, value_name = "SCOPE")]
1437 scope: String,
1438 /// The scope's reference (a cycle name or stable id, a team label, an identity)
1439 #[arg(long, value_name = "ID")]
1440 id: String,
1441 /// The ceiling, in `--unit`, at or above zero
1442 ///
1443 /// A negative value is accepted by the parser and refused by the shared
1444 /// validator with its field path (`capacity.<id>.capacity`), which is a better
1445 /// message than clap's "unexpected argument".
1446 #[arg(long, value_name = "N", allow_hyphen_values = true)]
1447 value: i64,
1448 /// The estimate unit the ceiling counts in (declared by the active board)
1449 #[arg(long, value_name = "UNIT")]
1450 unit: String,
1451 /// Keep the value and the unit, but enforce nothing
1452 #[arg(long)]
1453 disabled: bool,
1454 },
1455 /// Delete one scope's ceiling
1456 Delete {
1457 /// Scope dimension: `cycle`, `team` or `assignee`
1458 #[arg(long, value_name = "SCOPE")]
1459 scope: String,
1460 /// The scope's reference (a cycle name or stable id, a team label, an identity)
1461 #[arg(long, value_name = "ID")]
1462 id: String,
1463 },
1464 /// Calculate one scope's committed/completed/remaining/overflow/utilization
1465 ///
1466 /// Every value comes from the shared calculation DTO; a value the calculation
1467 /// could not derive prints as `-`, never as a zero. Findings go to stderr.
1468 Calculate {
1469 /// Scope dimension: `cycle`, `team` or `assignee`
1470 #[arg(long, value_name = "SCOPE")]
1471 scope: String,
1472 /// The scope's reference (a cycle name or stable id, a team label, an identity)
1473 #[arg(long, value_name = "ID")]
1474 id: String,
1475 /// Also render the overflow report (estimate and reference findings always render)
1476 #[arg(long)]
1477 warnings: bool,
1478 },
1479 /// Read back a closed cycle's immutable capacity evidence
1480 Snapshot {
1481 /// Cycle name or stable `<schedule-id>:<sequence>` id
1482 #[arg(value_name = "CYCLE")]
1483 cycle: String,
1484 /// Also render the overflow finding the close recorded
1485 #[arg(long)]
1486 warnings: bool,
1487 },
1488}
1489
1490/// The `tix time` subcommands (mission `per-issue-time-tracking-01M3C9A4`, WP04).
1491///
1492/// A thin adapter: the entry model and its validation, the mutation service and its
1493/// append-only History, and every filter, total and finding live under
1494/// `crate::time_tracking`. This enum owns only the spelling of the surface — the
1495/// duration is whole minutes, a date is a calendar `YYYY-MM-DD`, and there is no flag
1496/// here that could express a timer, a timezone, an overlap rule or a billing unit
1497/// (C-002/C-009).
1498#[derive(Debug, Clone, Subcommand)]
1499pub enum TimeCommand {
1500 /// Record effort against an issue, in whole minutes
1501 ///
1502 /// The date is optional and is a calendar date, never an instant: an entry without
1503 /// one is real, and a period-bounded read reports it as undated rather than as zero.
1504 Add {
1505 /// Issue number, filename fragment or unique title fragment
1506 #[arg(value_name = "ISSUE")]
1507 issue: String,
1508 /// The effort, in whole minutes at or above zero
1509 ///
1510 /// A negative value is accepted by the parser and refused by the shared entry
1511 /// validator with its field name, which is a better message than clap's
1512 /// "unexpected argument".
1513 #[arg(long, value_name = "N", allow_hyphen_values = true)]
1514 minutes: i64,
1515 /// The calendar date the effort belongs to, `YYYY-MM-DD`
1516 #[arg(long, value_name = "YYYY-MM-DD")]
1517 date: Option<String>,
1518 /// Who did the work: a handle (`ada`, `@ada`) or a `Name <email>` identity
1519 #[arg(long, value_name = "IDENTITY")]
1520 author: String,
1521 /// Free-text context, preserved verbatim
1522 #[arg(long, value_name = "TEXT")]
1523 note: Option<String>,
1524 },
1525 /// Correct one entry, naming only the fields that change
1526 ///
1527 /// A field the command does not name is left exactly as it was, including the entry's
1528 /// date, note and any metadata another build attached. `--clear-date` and
1529 /// `--clear-note` are how an optional value is removed (an empty `--note ""` would be
1530 /// a value, not an absence). A correction requires `--reason`: the prior value stays
1531 /// in History, and the reason is what makes the change attributable.
1532 Edit {
1533 /// Issue number, filename fragment or unique title fragment
1534 #[arg(value_name = "ISSUE")]
1535 issue: String,
1536 /// The stable id of the entry to correct (`tix time show <ISSUE>` lists them)
1537 #[arg(long, value_name = "ID")]
1538 entry: String,
1539 #[command(flatten)]
1540 change: TimeEditArgs,
1541 },
1542 /// Remove one entry from the issue's current entries
1543 ///
1544 /// The entry leaves the frontmatter; its value and its event stay in History, so a
1545 /// deletion is auditable rather than a rewrite. Requires `--reason` and `--confirm`.
1546 Delete {
1547 /// Issue number, filename fragment or unique title fragment
1548 #[arg(value_name = "ISSUE")]
1549 issue: String,
1550 /// The stable id of the entry to remove
1551 #[arg(long, value_name = "ID")]
1552 entry: String,
1553 /// Why the entry is being removed; recorded in History
1554 #[arg(long, value_name = "TEXT")]
1555 reason: Option<String>,
1556 /// Confirm the removal without prompting
1557 #[arg(long)]
1558 confirm: bool,
1559 },
1560 /// Show an issue's current entries and its total
1561 Show {
1562 /// Issue number, filename fragment or unique title fragment
1563 #[arg(value_name = "ISSUE")]
1564 issue: String,
1565 },
1566 /// Show what History retains for an issue: current entries beside its audit
1567 History {
1568 /// Issue number, filename fragment or unique title fragment
1569 #[arg(value_name = "ISSUE")]
1570 issue: String,
1571 },
1572 /// Total recorded effort, by issue, assignee and calendar period
1573 ///
1574 /// Every value comes from the shared calculation; a scope with nothing to total and a
1575 /// scope whose entries carry no usable duration print `-` with their state, never a
1576 /// zero. Deleted entries are excluded unless `--audit` asks for them.
1577 Total {
1578 /// Issue number, filename fragment or unique title fragment (alternative to `--issue`)
1579 #[arg(value_name = "ISSUE")]
1580 selector: Option<String>,
1581 #[command(flatten)]
1582 filter: TimeTotalArgs,
1583 },
1584}
1585
1586/// The fields an entry correction can name (T020).
1587///
1588/// A patch, not a replacement: a flag that is absent leaves its field alone, which is why
1589/// removal is spelled `--clear-date`/`--clear-note` rather than an empty value. The reason
1590/// is required — by the service, which refuses a missing one by name — and is what the
1591/// History event keeps once the prior value is replaced.
1592#[derive(Debug, Clone, Args)]
1593pub struct TimeEditArgs {
1594 /// Replacement duration, in whole minutes
1595 #[arg(long, value_name = "N", allow_hyphen_values = true)]
1596 pub minutes: Option<i64>,
1597 /// Replacement calendar date, `YYYY-MM-DD`
1598 #[arg(long, value_name = "YYYY-MM-DD", conflicts_with = "clear_date")]
1599 pub date: Option<String>,
1600 /// Remove the entry's date
1601 #[arg(long)]
1602 pub clear_date: bool,
1603 /// Replacement note
1604 #[arg(long, value_name = "TEXT", conflicts_with = "clear_note")]
1605 pub note: Option<String>,
1606 /// Remove the entry's note
1607 #[arg(long)]
1608 pub clear_note: bool,
1609 /// Replacement author identity
1610 #[arg(long, value_name = "IDENTITY")]
1611 pub author: Option<String>,
1612 /// Why the entry is being corrected; recorded in History, and required
1613 #[arg(long, value_name = "TEXT")]
1614 pub reason: Option<String>,
1615}
1616
1617/// The read-only scope a `tix time total` is evaluated under (T021).
1618///
1619/// Every criterion is optional and every named one must match, and the period is
1620/// inclusive. The dates are the caller's: this surface never resolves a `today`, so a
1621/// total means exactly the calendar window it was asked for.
1622#[derive(Debug, Clone, Args)]
1623pub struct TimeTotalArgs {
1624 /// Stable issue number to scope the total to
1625 #[arg(long, value_name = "N")]
1626 pub issue: Option<u32>,
1627 /// The identity that did the work (an entry's author)
1628 #[arg(long, value_name = "IDENTITY")]
1629 pub assignee: Option<String>,
1630 /// Inclusive period start, `YYYY-MM-DD`
1631 #[arg(long, value_name = "YYYY-MM-DD")]
1632 pub from: Option<String>,
1633 /// Inclusive period end, `YYYY-MM-DD`
1634 #[arg(long, value_name = "YYYY-MM-DD")]
1635 pub to: Option<String>,
1636 /// Also report the entries deletions left in History (never added to the total)
1637 #[arg(long)]
1638 pub audit: bool,
1639 /// Break the total down: `issue`, `assignee` or `date`
1640 #[arg(long, value_name = "DIMENSION")]
1641 pub by: Option<String>,
1642}
1643
1644#[derive(Subcommand)]
1645pub enum MilestoneCommand {
1646 Create {
1647 #[arg(long)]
1648 name: String,
1649 #[arg(long = "target-date")]
1650 target_date: Option<String>,
1651 #[arg(long)]
1652 description: Option<String>,
1653 },
1654 List,
1655 Edit {
1656 #[arg(long)]
1657 name: String,
1658 #[arg(long = "new-name")]
1659 new_name: Option<String>,
1660 #[arg(long = "target-date")]
1661 target_date: Option<String>,
1662 #[arg(long)]
1663 description: Option<String>,
1664 },
1665 Delete {
1666 #[arg(long)]
1667 name: String,
1668 #[arg(long)]
1669 yes: bool,
1670 },
1671}
1672
1673/// The `tix project` subcommands (mission `first-class-projects-01M3BYBW`, WP05).
1674///
1675/// A **required** subcommand, like `tix milestone` and `tix cycle`: `tix project`
1676/// alone is a usage error, not a listing, because guessing which of ten verbs the
1677/// user meant is worse than asking.
1678///
1679/// Selection is by the stable id (the registry key); a name is accepted as a
1680/// convenience and resolves to the same project, since `tix.toml` guarantees the
1681/// names are unique.
1682#[derive(Subcommand)]
1683pub enum ProjectCommand {
1684 /// Create a project and allocate a stable id
1685 ///
1686 /// The name is a display label, not the identity: it can be changed later with
1687 /// `tix project edit --name`, and every issue association survives because the
1688 /// issues reference the id.
1689 Create {
1690 /// Display name (unique within the registry, case-insensitively)
1691 #[arg(value_name = "NAME")]
1692 name: String,
1693 /// Long-form context
1694 #[arg(long)]
1695 description: Option<String>,
1696 /// Free-form project status (not a board column)
1697 #[arg(long)]
1698 status: Option<String>,
1699 /// Lead identity string
1700 #[arg(long)]
1701 lead: Option<String>,
1702 /// Start date (`YYYY-MM-DD`)
1703 #[arg(long = "start-date")]
1704 start_date: Option<String>,
1705 /// Due date (`YYYY-MM-DD`); must be `>= start-date`
1706 #[arg(long = "due-date")]
1707 due_date: Option<String>,
1708 },
1709 /// Edit a project's fields; a rename changes the label only
1710 ///
1711 /// Nothing is re-keyed: the id stays, so every association and every derived
1712 /// figure is untouched. An optional text flag with a blank value clears it
1713 /// (`--lead ""`), the same convention `tix edit` uses.
1714 Edit {
1715 /// Project id (a name is accepted as a convenience)
1716 #[arg(value_name = "ID")]
1717 reference: String,
1718 /// New display name
1719 #[arg(long)]
1720 name: Option<String>,
1721 /// New long-form context (`--description ""` clears)
1722 #[arg(long)]
1723 description: Option<String>,
1724 /// New free-form status (`--status ""` clears)
1725 #[arg(long)]
1726 status: Option<String>,
1727 /// New lead identity string (`--lead ""` clears)
1728 #[arg(long)]
1729 lead: Option<String>,
1730 /// New start date (`YYYY-MM-DD`; `--start-date ""` clears)
1731 #[arg(long = "start-date")]
1732 start_date: Option<String>,
1733 /// New due date (`YYYY-MM-DD`; `--due-date ""` clears)
1734 #[arg(long = "due-date")]
1735 due_date: Option<String>,
1736 },
1737 /// List projects in declaration order, with ids and member counts
1738 List,
1739 /// Show one project's record and its members
1740 Show {
1741 /// Project id (a name is accepted as a convenience)
1742 #[arg(value_name = "ID")]
1743 reference: String,
1744 },
1745 /// Delete a project's registry entry (its issues are never deleted)
1746 ///
1747 /// The issues that referenced the project keep their references; the report
1748 /// names them so the dangling references can be repaired deliberately.
1749 Delete {
1750 /// Project id (a name is accepted as a convenience)
1751 #[arg(value_name = "ID")]
1752 reference: String,
1753 /// Confirm without prompting
1754 #[arg(long)]
1755 yes: bool,
1756 },
1757 /// Associate one or more issues with a project
1758 ///
1759 /// Each selector is processed independently: a rejection on one is reported
1760 /// and does not abort the rest.
1761 Add {
1762 /// Project id (a name is accepted as a convenience)
1763 #[arg(value_name = "ID")]
1764 reference: String,
1765 /// Issue numbers, filename fragments or unique title fragments
1766 #[arg(required = true, value_name = "ISSUE")]
1767 issues: Vec<String>,
1768 },
1769 /// Dissociate one or more issues from a project
1770 ///
1771 /// Drops only the association; no issue is deleted, and each selector is
1772 /// processed independently.
1773 ///
1774 /// A reference that does not name a live project is used **verbatim**, because
1775 /// removing a retained stale id is the repair path a project deletion leaves
1776 /// open (C-005). A typo that matches nothing is therefore an inert no-op that
1777 /// reports `0 changed`, not a refusal — which is the one place this differs
1778 /// from `tix edit <issue> --remove-project <id>`, where an unknown reference the
1779 /// issue does not carry is refused as `unknown_project`.
1780 Remove {
1781 /// Project id (a name is accepted as a convenience)
1782 #[arg(value_name = "ID")]
1783 reference: String,
1784 /// Issue numbers, filename fragments or unique title fragments
1785 #[arg(required = true, value_name = "ISSUE")]
1786 issues: Vec<String>,
1787 },
1788 /// Bulk-associate one or more issues with a project
1789 ///
1790 /// Project association only — general bulk editing is a different feature.
1791 /// Every selector is processed independently and every outcome is reported,
1792 /// so one unknown selector does not discard the associations that succeeded.
1793 ///
1794 /// An unknown **project** reference refuses the whole command before anything
1795 /// is written (all entries then fail with `unknown_project`); use
1796 /// `tix project remove` to drop a retained stale id, whose reference is matched
1797 /// verbatim and reported as `0 changed` rather than refused.
1798 BulkAdd {
1799 /// Project id (a name is accepted as a convenience)
1800 #[arg(value_name = "ID")]
1801 reference: String,
1802 /// Issue numbers, filename fragments or unique title fragments
1803 #[arg(required = true, value_name = "ISSUE")]
1804 issues: Vec<String>,
1805 },
1806}
1807
1808/// The `tix epic` subcommands (mission `epics-work-items-01M3BYW4`, WP05).
1809///
1810/// A **required** subcommand, like `tix project`. Selection is by the stable id
1811/// (the registry key); a name resolves to the same epic as a convenience. A work
1812/// item is an ordinary issue (`tix create --epics`, `tix edit --add-epic`), so
1813/// there is no task verb here.
1814#[derive(Subcommand)]
1815pub enum EpicCommand {
1816 /// Create an epic and allocate a stable id
1817 ///
1818 /// The name is a display label, not the identity: `tix epic edit --name`
1819 /// changes it and every issue reference survives, because issues reference the id.
1820 Create {
1821 /// Display name (unique within the registry, case-insensitively)
1822 #[arg(value_name = "NAME")]
1823 name: String,
1824 /// Long-form context
1825 #[arg(long)]
1826 description: Option<String>,
1827 /// Free-form epic status (not a board column, not a category)
1828 #[arg(long)]
1829 status: Option<String>,
1830 /// Owner identity string
1831 #[arg(long)]
1832 owner: Option<String>,
1833 /// Start date (`YYYY-MM-DD`)
1834 #[arg(long = "start-date")]
1835 start_date: Option<String>,
1836 /// Due date (`YYYY-MM-DD`); must be `>= start-date`
1837 #[arg(long = "due-date")]
1838 due_date: Option<String>,
1839 /// Positive relative size, used by the epic rollup
1840 ///
1841 /// `allow_hyphen_values` so `--weight -5` reaches the service's
1842 /// `invalid_weight` refusal rather than being swallowed by clap as an
1843 /// unknown flag — the same invalid input must report one thing whichever
1844 /// spelling is used.
1845 #[arg(long, allow_hyphen_values = true)]
1846 weight: Option<String>,
1847 /// Project ids this epic concerns, comma-separated
1848 ///
1849 /// Shape-checked only, never resolved: a well-formed id the registry does
1850 /// not hold is stored and diagnosed by `tix lint`, so it is repairable.
1851 #[arg(long, value_name = "ID")]
1852 projects: Option<String>,
1853 /// Milestone name; stored verbatim, never resolved
1854 #[arg(long)]
1855 milestone: Option<String>,
1856 /// Cycle name; stored verbatim, never resolved
1857 #[arg(long)]
1858 cycle: Option<String>,
1859 },
1860 /// Edit an epic's fields; a rename changes the label only
1861 ///
1862 /// Nothing is re-keyed, so every issue reference and derived figure stays
1863 /// intact. A blank optional value clears the field (`--owner ""`).
1864 Edit {
1865 /// Epic id (a name is accepted as a convenience)
1866 #[arg(value_name = "ID")]
1867 reference: String,
1868 /// New display name
1869 #[arg(long)]
1870 name: Option<String>,
1871 /// New long-form context (`--description ""` clears)
1872 #[arg(long)]
1873 description: Option<String>,
1874 /// New free-form status (`--status ""` clears)
1875 #[arg(long)]
1876 status: Option<String>,
1877 /// New owner identity string (`--owner ""` clears)
1878 #[arg(long)]
1879 owner: Option<String>,
1880 /// New start date (`--start-date ""` clears)
1881 #[arg(long = "start-date")]
1882 start_date: Option<String>,
1883 /// New due date (`--due-date ""` clears)
1884 #[arg(long = "due-date")]
1885 due_date: Option<String>,
1886 /// New positive relative size (`--weight ""` clears)
1887 ///
1888 /// `allow_hyphen_values` so `--weight -5` reaches the service's
1889 /// `invalid_weight` refusal rather than clap's unknown-flag error.
1890 #[arg(long, allow_hyphen_values = true)]
1891 weight: Option<String>,
1892 /// Set or change the parent epic; `--parent ""` detaches it
1893 ///
1894 /// Validated by the epic service before any write, so a self-parent or a
1895 /// cycle-closing change leaves the registry byte-identical.
1896 #[arg(long, value_name = "ID")]
1897 parent: Option<String>,
1898 /// Replace the whole project list, comma-separated (`--projects ""` clears)
1899 ///
1900 /// Shape-checked only, never resolved: a dangling but well-formed id is
1901 /// stored and diagnosed by `tix lint`.
1902 #[arg(long, value_name = "ID")]
1903 projects: Option<String>,
1904 /// New milestone name (`--milestone ""` clears); stored verbatim
1905 #[arg(long)]
1906 milestone: Option<String>,
1907 /// New cycle name (`--cycle ""` clears); stored verbatim
1908 #[arg(long)]
1909 cycle: Option<String>,
1910 },
1911 /// List epics in declaration order, with ids, member and child counts
1912 List,
1913 /// Show one epic's record, hierarchy, progress and members
1914 Show {
1915 /// Epic id (a name is accepted as a convenience)
1916 #[arg(value_name = "ID")]
1917 reference: String,
1918 },
1919 /// Navigate an epic's hierarchy: parent, children, ancestors and depth
1920 ///
1921 /// Direct issue navigation stays available: the member list is included and a
1922 /// member is an ordinary issue, reachable with `tix show <number>`.
1923 Navigate {
1924 /// Epic id (a name is accepted as a convenience)
1925 #[arg(value_name = "ID")]
1926 reference: String,
1927 },
1928 /// Delete an epic's registry entry (its issues and child epics are never deleted)
1929 ///
1930 /// The issues that referenced the epic keep their references and the child
1931 /// epics keep their parent reference; both are reported so the dangling
1932 /// references can be repaired deliberately.
1933 Delete {
1934 /// Epic id (a name is accepted as a convenience)
1935 #[arg(value_name = "ID")]
1936 reference: String,
1937 /// Confirm without prompting
1938 #[arg(long)]
1939 yes: bool,
1940 },
1941 /// Reference an epic from one or more issues
1942 ///
1943 /// Each selector is processed independently: a rejection on one is reported
1944 /// and does not abort the rest.
1945 Add {
1946 /// Epic id (a name is accepted as a convenience)
1947 #[arg(value_name = "ID")]
1948 reference: String,
1949 /// Issue numbers, filename fragments or unique title fragments
1950 #[arg(required = true, value_name = "ISSUE")]
1951 issues: Vec<String>,
1952 },
1953 /// Remove an epic reference from one or more issues
1954 ///
1955 /// Drops only the reference; no issue is deleted. A reference that does not
1956 /// name a live epic is used **verbatim**, so a retained stale id can still be
1957 /// removed and is reported as `0 changed` rather than refused.
1958 Remove {
1959 /// Epic id (a name is accepted as a convenience)
1960 #[arg(value_name = "ID")]
1961 reference: String,
1962 /// Issue numbers, filename fragments or unique title fragments
1963 #[arg(required = true, value_name = "ISSUE")]
1964 issues: Vec<String>,
1965 },
1966 /// Bulk-reference one or more issues from an epic
1967 ///
1968 /// Epic reference only — general bulk editing is a different feature. Every
1969 /// selector is independent, so one unknown selector does not discard the rest.
1970 /// An unknown **epic** reference refuses the whole command before anything is
1971 /// written; use `tix epic remove` to drop a retained stale id.
1972 BulkAdd {
1973 /// Epic id (a name is accepted as a convenience)
1974 #[arg(value_name = "ID")]
1975 reference: String,
1976 /// Issue numbers, filename fragments or unique title fragments
1977 #[arg(required = true, value_name = "ISSUE")]
1978 issues: Vec<String>,
1979 },
1980}
1981
1982/// The `tix initiative` subcommands (mission `roadmap-initiatives-01M3C8SX`, WP04).
1983///
1984/// A **required** subcommand, like `tix project` and `tix epic`. Selection is by the
1985/// stable id (the registry key); a name resolves to the same initiative as a
1986/// convenience. A rename through `edit --name` changes the label only: the id is not
1987/// re-keyed, so every association and derived value survives.
1988#[derive(Subcommand)]
1989pub enum InitiativeCommand {
1990 /// Create an initiative and allocate a stable id
1991 ///
1992 /// The name is a display label, not the identity: `tix initiative edit --name`
1993 /// changes it and every association survives, because the record is keyed by id.
1994 Create {
1995 /// Display name (unique within the registry, case-insensitively)
1996 #[arg(value_name = "NAME")]
1997 name: String,
1998 /// Optional strategic outcome
1999 #[arg(long)]
2000 outcome: Option<String>,
2001 /// Owner identity string; no lookup, never inferred
2002 #[arg(long)]
2003 owner: Option<String>,
2004 /// Free-form initiative status (not a board column)
2005 #[arg(long)]
2006 status: Option<String>,
2007 /// Initiative priority (a known spelling is normalized; an unknown one is kept)
2008 #[arg(long)]
2009 priority: Option<String>,
2010 /// Filterable labels, comma-separated
2011 #[arg(long)]
2012 labels: Option<String>,
2013 /// Target date (`YYYY-MM-DD`)
2014 #[arg(long = "target-date")]
2015 target_date: Option<String>,
2016 /// Project ids this initiative concerns, comma-separated
2017 ///
2018 /// Shape-checked only, never resolved: a well-formed id the registry does not
2019 /// hold is stored and diagnosed by `tix roadmap`/`tix lint`, so it is
2020 /// repairable.
2021 #[arg(long, value_name = "ID")]
2022 projects: Option<String>,
2023 /// Epic ids this initiative concerns, comma-separated
2024 #[arg(long, value_name = "ID")]
2025 epics: Option<String>,
2026 /// Milestone names this initiative concerns, comma-separated
2027 #[arg(long, value_name = "NAME")]
2028 milestones: Option<String>,
2029 },
2030 /// Edit an initiative's fields; a rename changes the label only
2031 ///
2032 /// Nothing is re-keyed, so every association and derived value stays intact. An
2033 /// optional value with a blank spelling clears the field (`--owner ""`), and a
2034 /// blank reference list clears it (`--projects ""`).
2035 Edit {
2036 /// Initiative id (a name is accepted as a convenience)
2037 #[arg(value_name = "ID")]
2038 reference: String,
2039 /// New display name
2040 #[arg(long)]
2041 name: Option<String>,
2042 /// New strategic outcome (`--outcome ""` clears)
2043 #[arg(long)]
2044 outcome: Option<String>,
2045 /// New owner identity string (`--owner ""` clears)
2046 #[arg(long)]
2047 owner: Option<String>,
2048 /// New free-form status (`--status ""` clears)
2049 #[arg(long)]
2050 status: Option<String>,
2051 /// New priority (`--priority ""` clears)
2052 #[arg(long)]
2053 priority: Option<String>,
2054 /// Replace the label list, comma-separated (`--labels ""` clears)
2055 #[arg(long)]
2056 labels: Option<String>,
2057 /// New target date (`YYYY-MM-DD`; `--target-date ""` clears)
2058 #[arg(long = "target-date")]
2059 target_date: Option<String>,
2060 /// Replace the project ids, comma-separated (`--projects ""` clears)
2061 #[arg(long, value_name = "ID")]
2062 projects: Option<String>,
2063 /// Replace the epic ids, comma-separated (`--epics ""` clears)
2064 #[arg(long, value_name = "ID")]
2065 epics: Option<String>,
2066 /// Replace the milestone names, comma-separated (`--milestones ""` clears)
2067 #[arg(long, value_name = "NAME")]
2068 milestones: Option<String>,
2069 },
2070 /// List initiatives in declaration order, with ids, target dates and status
2071 List,
2072 /// Show one initiative's record, metadata and references
2073 Show {
2074 /// Initiative id (a name is accepted as a convenience)
2075 #[arg(value_name = "ID")]
2076 reference: String,
2077 },
2078 /// Delete an initiative's registry entry (nothing else is ever deleted)
2079 ///
2080 /// The record's own references are reported and nothing cascades: no project,
2081 /// epic, milestone or issue is touched, and no association is silently repaired.
2082 Delete {
2083 /// Initiative id (a name is accepted as a convenience)
2084 #[arg(value_name = "ID")]
2085 reference: String,
2086 /// Confirm without prompting
2087 #[arg(long)]
2088 yes: bool,
2089 },
2090 /// Associate an initiative with exactly one project, epic or milestone
2091 ///
2092 /// Name exactly one of `--project`, `--epic`, `--milestone`; the kind is part of
2093 /// the record, so guessing is worse than asking. A well-formed reference that
2094 /// names nothing is stored and stays diagnosable, and a repeated association is
2095 /// an idempotent no-op that writes no bytes.
2096 Associate {
2097 /// Initiative id (a name is accepted as a convenience)
2098 #[arg(value_name = "ID")]
2099 reference: String,
2100 /// Project **id** to associate
2101 #[arg(long, value_name = "ID")]
2102 project: Option<String>,
2103 /// Epic **id** to associate
2104 #[arg(long, value_name = "ID")]
2105 epic: Option<String>,
2106 /// Milestone **name** to associate
2107 #[arg(long, value_name = "NAME")]
2108 milestone: Option<String>,
2109 },
2110 /// Drop an explicit association from an initiative
2111 ///
2112 /// Name exactly one of `--project`, `--epic`, `--milestone`. Dropping an
2113 /// association that is not recorded is an idempotent no-op that writes no bytes.
2114 Dereference {
2115 /// Initiative id (a name is accepted as a convenience)
2116 #[arg(value_name = "ID")]
2117 reference: String,
2118 /// Project **id** to drop
2119 #[arg(long, value_name = "ID")]
2120 project: Option<String>,
2121 /// Epic **id** to drop
2122 #[arg(long, value_name = "ID")]
2123 epic: Option<String>,
2124 /// Milestone **name** to drop
2125 #[arg(long, value_name = "NAME")]
2126 milestone: Option<String>,
2127 },
2128}
2129
2130/// The `tix roadmap` subcommands (mission `roadmap-initiatives-01M3C8SX`, WP04).
2131///
2132/// Read-only. `list` applies the one normalized [`crate::roadmap::filter::RoadmapFilter`];
2133/// `show` prints the single-row detail in the same rendering.
2134#[derive(Subcommand)]
2135pub enum RoadmapCommand {
2136 /// List initiatives in roadmap order: target date ascending, then stable id
2137 ///
2138 /// An undated initiative sorts after every dated one and renders the explicit
2139 /// `undated` state — never a blank or an invented date. A bounded period filter
2140 /// excludes it, because inventing a period is exactly what the contract forbids.
2141 List {
2142 /// Inclusive lower bound on the target date (`YYYY-MM-DD`)
2143 #[arg(long, value_name = "DATE")]
2144 from: Option<String>,
2145 /// Inclusive upper bound on the target date (`YYYY-MM-DD`)
2146 #[arg(long, value_name = "DATE")]
2147 to: Option<String>,
2148 /// Owner to match, compared trimmed and case-insensitively
2149 #[arg(long, value_name = "ID")]
2150 owner: Option<String>,
2151 /// Initiative status to match, compared trimmed and case-insensitively
2152 #[arg(long, value_name = "STATE")]
2153 status: Option<String>,
2154 /// A label the initiative must carry; repeat for an AND over labels
2155 #[arg(long = "label", value_name = "LABEL")]
2156 labels: Vec<String>,
2157 /// Free text matched case-insensitively against the searchable fields
2158 #[arg(long, value_name = "TEXT")]
2159 query: Option<String>,
2160 },
2161 /// Show one initiative's roadmap row: summary contributions and diagnostics
2162 Show {
2163 /// Initiative id (a name is accepted as a convenience)
2164 #[arg(value_name = "ID")]
2165 reference: String,
2166 },
2167}
2168
2169/// The `tix parent` subcommands.
2170///
2171/// A **required** subcommand, unlike `tix link`'s three optional verbs. `tix link`
2172/// has a meaningful bare form (create); `tix parent` with no argument is a user
2173/// mistake, and guessing which of three reports they wanted is worse than asking.
2174#[derive(Debug, Clone, Subcommand)]
2175pub enum ParentAction {
2176 /// Report the parent, direct children, child count and rollup for one issue
2177 Show {
2178 /// Issue number, filename fragment or unique title fragment
2179 issue: String,
2180 },
2181 /// List an issue's direct children, ordered by issue number
2182 Children {
2183 /// Issue number, filename fragment or unique title fragment
2184 issue: String,
2185 },
2186 /// List every issue that has children, with a child count
2187 List,
2188}
2189
2190/// The `tix workflow` verbs, per `contracts/cli-surface.md` §1.
2191///
2192/// A real `#[command(subcommand)]` tree rather than an optional-verb enum like
2193/// [`LinkAction`]: the three verbs share nothing but the prefix, and there is no
2194/// sensible bare `tix workflow`. Making the verb **required** means `tix workflow`
2195/// alone is a usage error rather than an empty success — printing the vocabulary
2196/// when the user asked for the policy would be guessing at their intent.
2197#[derive(Subcommand)]
2198pub enum WorkflowAction {
2199 /// The global category vocabulary, in order
2200 Categories,
2201 /// A board's columns, resolved categories, and where each resolution came from
2202 Show {
2203 /// Board name; omitted means the active board (`default`, or the first
2204 /// declared). A name that is not configured is an error listing the boards
2205 /// that are.
2206 board: Option<String>,
2207 },
2208 /// The effective transition policy and its override escape hatch
2209 Transitions,
2210}
2211
2212/// `tix layout` subcommands.
2213#[derive(Subcommand)]
2214pub enum LayoutAction {
2215 /// Print the effective `[layout]` template and a worked example
2216 Show,
2217 /// Relocate every issue to the configured layout (dry run unless `--apply`)
2218 Migrate {
2219 /// Write the moves; without it the command only prints the plan
2220 #[arg(long)]
2221 apply: bool,
2222 },
2223}
2224
2225/// The `tix link` subcommands.
2226///
2227/// `tix link <from> <to> --type <kind>` is the create form and carries no
2228/// subcommand, so the three verbs below are optional rather than required
2229/// (C-001 removes the old untyped symmetric form, not the bare create form the
2230/// CLI surface contract specifies).
2231#[derive(Subcommand)]
2232pub enum LinkAction {
2233 /// Retarget and/or re-kind an existing relationship, keeping its id
2234 Edit {
2235 /// Relationship id, as printed by `tix link list`
2236 id: String,
2237 /// New target issue number, filename fragment or unique title fragment
2238 #[arg(long)]
2239 to: Option<String>,
2240 /// New relationship kind: `blocks`, `blocked_by`, `duplicates`, `related`
2241 #[arg(long, value_name = "KIND")]
2242 r#type: Option<String>,
2243 },
2244 /// Remove a relationship by id. An absent id is an idempotent no-op
2245 Remove {
2246 /// Relationship id, as printed by `tix link list`
2247 id: String,
2248 },
2249 /// List relationships for one issue, or for the whole board
2250 List {
2251 /// Issue number, filename fragment or unique title fragment. Omit for every issue.
2252 issue: Option<String>,
2253 /// Only relationships of this kind
2254 #[arg(long, value_name = "KIND")]
2255 kind: Option<String>,
2256 },
2257}
2258
2259/// What `tix recurring` does.
2260///
2261/// Every read-only subcommand resolves its date explicitly (`--date`), and every
2262/// mutating one requires the confirmation flag it documents. The subcommands are a
2263/// thin adapter: they parse, call one service, and print.
2264#[derive(Debug, Clone, Subcommand)]
2265pub enum RecurringAction {
2266 /// List every series, one tab-separated row per series
2267 ///
2268 /// Columns: `id`, `name`, `state`, `next_cursor`, `skipped`.
2269 List,
2270 /// Show one series: identity, rule, state, cursor, skipped dates, occurrences
2271 Show {
2272 /// Stable series id, or its unique display name
2273 series: String,
2274 },
2275 /// Create a series: the issue that *is* the definition
2276 ///
2277 /// The issue's own fields are the template an occurrence is created from, so
2278 /// `--title`, `--labels` and the rest are also what each occurrence inherits.
2279 /// The rule is validated **before** the issue is written.
2280 Create(RecurringCreateArgs),
2281 /// Change a series' name and/or rule, keeping its stable id
2282 ///
2283 /// Only the flags you name change; the rest of the rule is carried over, so
2284 /// `tix recurring edit <id> --name X` is a rename and nothing else.
2285 Edit {
2286 /// Stable series id, or its unique display name
2287 series: String,
2288 #[command(flatten)]
2289 args: RecurringEditArgs,
2290 },
2291 /// Report what is due, read-only, for an explicit date
2292 Detect {
2293 /// Evaluate against this `YYYY-MM-DD` date instead of today
2294 #[arg(long, value_name = "YYYY-MM-DD")]
2295 date: Option<String>,
2296 /// Timezone policy to carry on the context, e.g. `+02:00`
2297 #[arg(long, value_name = "TZ")]
2298 timezone: Option<String>,
2299 },
2300 /// Create the occurrence for one due date; requires `--yes`
2301 ///
2302 /// Idempotent: the occurrence key is checked first, so a repeated call is a
2303 /// no-op and never a second issue.
2304 Generate {
2305 /// Stable series id, or its unique display name
2306 series: String,
2307 /// The occurrence date, `YYYY-MM-DD`
2308 #[arg(long, value_name = "YYYY-MM-DD")]
2309 date: String,
2310 #[command(flatten)]
2311 confirmation: RecurringLifecycleArgs,
2312 },
2313 /// Suspend generation until an explicit resume
2314 Pause {
2315 /// Stable series id, or its unique display name
2316 series: String,
2317 #[command(flatten)]
2318 confirmation: RecurringLifecycleArgs,
2319 },
2320 /// Resume a paused series; never inferred from a refresh
2321 Resume {
2322 /// Stable series id, or its unique display name
2323 series: String,
2324 #[command(flatten)]
2325 confirmation: RecurringLifecycleArgs,
2326 },
2327 /// End a series: terminal for generation
2328 End {
2329 /// Stable series id, or its unique display name
2330 series: String,
2331 #[command(flatten)]
2332 confirmation: RecurringLifecycleArgs,
2333 },
2334 /// Record one occurrence date as skipped, so it is never generated
2335 Skip {
2336 /// Stable series id, or its unique display name
2337 series: String,
2338 /// The occurrence date, `YYYY-MM-DD`
2339 #[arg(long, value_name = "YYYY-MM-DD")]
2340 date: String,
2341 #[command(flatten)]
2342 confirmation: RecurringLifecycleArgs,
2343 },
2344 /// Move the candidate past a date without recording it as skipped
2345 Defer {
2346 /// Stable series id, or its unique display name
2347 series: String,
2348 /// The occurrence date, `YYYY-MM-DD`
2349 #[arg(long, value_name = "YYYY-MM-DD")]
2350 date: String,
2351 #[command(flatten)]
2352 confirmation: RecurringLifecycleArgs,
2353 },
2354 /// Stop offering one occurrence date: recorded, like `skip`
2355 Ignore {
2356 /// Stable series id, or its unique display name
2357 series: String,
2358 /// The occurrence date, `YYYY-MM-DD`
2359 #[arg(long, value_name = "YYYY-MM-DD")]
2360 date: String,
2361 #[command(flatten)]
2362 confirmation: RecurringLifecycleArgs,
2363 },
2364}
2365
2366/// `tix wip`'s subcommands (mission `configurable-wip-limits-01M3C6WX`, WP04).
2367///
2368/// The CLI parses and routes; the policy service owns every evaluation, and the
2369/// schema's own validation decides whether a configuration is acceptable. Nothing
2370/// here decides what a limit *means*.
2371#[derive(Debug, Clone, Subcommand)]
2372pub enum WipAction {
2373 /// Show the configured limits, their units, and the current usage
2374 Show {
2375 /// Board name; default is the active board
2376 #[arg(long, value_name = "BOARD")]
2377 board: Option<String>,
2378 },
2379 /// Set or update one column's limit
2380 ///
2381 /// Only the flags you name change, exactly as `tix recurring edit` works, so
2382 /// `tix wip set "in progress" --max-items 4` keeps the column's estimate
2383 /// ceiling, unit and policy.
2384 Set {
2385 /// Column name, as `[kanban.<board>.columns]` spells it
2386 column: String,
2387 /// The most cards the column may hold
2388 #[arg(long, value_name = "N")]
2389 max_items: Option<u32>,
2390 /// The most estimate the column's cards may add up to
2391 #[arg(long, value_name = "N")]
2392 max_estimate: Option<u32>,
2393 /// The unit `--max-estimate` counts in; it must be declared with `tix wip unit`
2394 #[arg(long, value_name = "UNIT")]
2395 unit: Option<String>,
2396 /// What exceeding the limit does: `warn` or `block`
2397 #[arg(long, value_name = "warn|block")]
2398 policy: Option<String>,
2399 /// Keep the numbers but enforce nothing
2400 #[arg(long)]
2401 disabled: bool,
2402 /// Board name; default is the active board
2403 #[arg(long, value_name = "BOARD")]
2404 board: Option<String>,
2405 },
2406 /// Remove one column's limit
2407 Unset {
2408 /// Column name
2409 column: String,
2410 /// Board name; default is the active board
2411 #[arg(long, value_name = "BOARD")]
2412 board: Option<String>,
2413 },
2414 /// Declare an estimate unit, or remove one
2415 ///
2416 /// A unit is a name and its factor to the board's base unit: with `d = 1` and
2417 /// `w = 5`, an issue estimated `1w` counts as `5d`. A unit the board does not
2418 /// declare is a `missing_conversion` refusal wherever it is used, never a zero.
2419 Unit {
2420 /// Unit name, e.g. `d`
2421 name: String,
2422 /// The factor to the board's base unit (at least 1)
2423 factor: Option<u32>,
2424 /// Remove the unit instead of declaring it
2425 #[arg(long)]
2426 remove: bool,
2427 /// Board name; default is the active board
2428 #[arg(long, value_name = "BOARD")]
2429 board: Option<String>,
2430 },
2431}
2432
2433/// The rule flags `tix recurring create` and `edit` share.
2434#[derive(Debug, Clone, Args)]
2435pub struct RecurringRuleArgs {
2436 /// Family: `daily`, `weekly`, `monthly`, `interval`, `cron-like`
2437 #[arg(long, value_name = "KIND")]
2438 pub rule: Option<String>,
2439 /// Schedule origin, `YYYY-MM-DD` (required by every family)
2440 #[arg(long, value_name = "YYYY-MM-DD")]
2441 pub anchor: Option<String>,
2442 /// Cadence, where the family takes one (1-1000)
2443 #[arg(long, value_name = "N")]
2444 pub interval: Option<u32>,
2445 /// What an `interval` rule counts in: `days`, `weeks`, `months`
2446 #[arg(long, value_name = "UNIT")]
2447 pub unit: Option<String>,
2448 /// Cron-like expression: `@daily`, `@weekly`, `@monthly`
2449 #[arg(long, value_name = "EXPR")]
2450 pub expression: Option<String>,
2451 /// Timezone policy, e.g. `+02:00`
2452 #[arg(long, value_name = "TZ")]
2453 pub timezone: Option<String>,
2454}
2455
2456impl RecurringRuleArgs {
2457 /// Whether the caller named any rule field at all.
2458 pub const fn is_any(&self) -> bool {
2459 self.rule.is_some()
2460 || self.anchor.is_some()
2461 || self.interval.is_some()
2462 || self.unit.is_some()
2463 || self.expression.is_some()
2464 || self.timezone.is_some()
2465 }
2466}
2467
2468/// The confirmation every mutating `tix recurring` subcommand takes.
2469#[derive(Debug, Clone, Args)]
2470pub struct RecurringLifecycleArgs {
2471 /// Confirm the action without prompting
2472 #[arg(long)]
2473 pub yes: bool,
2474 /// Why; recorded in the series' History
2475 #[arg(long, value_name = "TEXT")]
2476 pub reason: Option<String>,
2477}
2478
2479/// The fields of the definition issue plus its rule.
2480#[derive(Debug, Clone, Args)]
2481pub struct RecurringCreateArgs {
2482 /// Title of the series issue (and the default series name)
2483 #[arg(long, value_name = "TITLE")]
2484 pub title: String,
2485 /// Body an occurrence inherits
2486 #[arg(long, value_name = "BODY")]
2487 pub body: Option<String>,
2488 /// Column the definition (and its occurrences) starts in
2489 #[arg(long, value_name = "STATUS")]
2490 pub status: Option<String>,
2491 /// Priority: `low`, `medium`, `high`
2492 #[arg(long, value_name = "PRIORITY")]
2493 pub priority: Option<String>,
2494 /// Labels, comma-separated
2495 #[arg(long, value_name = "LABELS")]
2496 pub labels: Option<String>,
2497 /// Assignee (`@name`)
2498 #[arg(long, value_name = "ASSIGNEE")]
2499 pub assignee: Option<String>,
2500 /// Display name; defaults to the title
2501 #[arg(long, value_name = "NAME")]
2502 pub name: Option<String>,
2503 /// Stable series id; generated when omitted
2504 #[arg(long, value_name = "ID")]
2505 pub id: Option<String>,
2506 #[command(flatten)]
2507 pub rule: RecurringRuleArgs,
2508}
2509
2510/// What `tix recurring edit` may change.
2511#[derive(Debug, Clone, Args)]
2512pub struct RecurringEditArgs {
2513 /// New display name; omitted means keep the current one
2514 #[arg(long, value_name = "NAME")]
2515 pub name: Option<String>,
2516 #[command(flatten)]
2517 pub rule: RecurringRuleArgs,
2518}
2519
2520/// The `tix view` saved-view subcommands (#172; mission
2521/// `saved-issue-views-01M3C926`, WP04).
2522///
2523/// A **real** subcommand tree, not an optional-verb enum like [`LinkAction`]:
2524/// there is no meaningful bare `tix view` (bare `tix view` would be the
2525/// issue-printer, which takes an issue selector), so every saved-view operation
2526/// names its verb. The nine verbs are the surface contract's spelling
2527/// (`contracts/cli-surface.md`).
2528///
2529/// The plan-shaping flags are [`ViewPlanArgs`] and the scope flags are
2530/// [`ViewScopeArgs`], flattened into the verbs that take them so `create`,
2531/// `update`, `share`, and `status` cannot drift apart.
2532#[derive(Subcommand)]
2533pub enum ViewAction {
2534 /// List saved views, in declaration order, as tab-separated rows
2535 List {
2536 /// Only views in this scope family: `repository` | `project` | `user`
2537 #[arg(long, value_name = "repository|project|user")]
2538 scope: Option<String>,
2539 },
2540 /// Show one saved view: stable id, name, scope, normalized plan, diagnostics
2541 Show {
2542 /// Stable view id, or its display name
2543 reference: String,
2544 },
2545 /// Save a new view from the existing query grammar
2546 ///
2547 /// The id is **generated** and stable (spec §3: "use stable generated IDs;
2548 /// names are display labels"); it is printed on the first stdout line so a
2549 /// script can capture it.
2550 Create {
2551 /// Display label
2552 #[arg(long, value_name = "NAME")]
2553 name: String,
2554 #[command(flatten)]
2555 scope: ViewScopeArgs,
2556 #[command(flatten)]
2557 plan: ViewPlanArgs,
2558 },
2559 /// Replace an existing view's definition, keeping its stable id
2560 ///
2561 /// Only the fields you name change; the rest are carried over from the
2562 /// stored definition, so `tix view update <id> --name X` is a rename and
2563 /// nothing else (identity survives a rename, C-002).
2564 Update {
2565 /// Stable view id, or its display name
2566 reference: String,
2567 /// New display label; omitted means keep the current one
2568 #[arg(long, value_name = "NAME")]
2569 name: Option<String>,
2570 #[command(flatten)]
2571 plan: ViewPlanArgs,
2572 },
2573 /// Copy a view under a **new** stable id
2574 Duplicate {
2575 /// Stable view id, or its display name
2576 reference: String,
2577 /// Label for the copy; omitted means the source's label
2578 #[arg(long, value_name = "NAME")]
2579 name: Option<String>,
2580 },
2581 /// Remove a view **definition** only; requires `--confirm`
2582 ///
2583 /// Never deletes an issue, comment, or relationship.
2584 Delete {
2585 /// Stable view id, or its display name
2586 reference: String,
2587 /// Acknowledge that only the saved definition is removed
2588 #[arg(long)]
2589 confirm: bool,
2590 },
2591 /// Apply a saved view read-only: render its plan, result, and active marker
2592 Apply {
2593 /// Stable view id, or its display name
2594 reference: String,
2595 },
2596 /// Report the saved plan against a live plan, field by field
2597 ///
2598 /// Without an id it reports the (absent) active marker — never a silent
2599 /// "in sync". With an id, the live plan is assembled from the plan flags
2600 /// given here, in the existing grammar.
2601 Status {
2602 /// Stable view id, or its display name; omitted reports the active marker
2603 reference: Option<String>,
2604 #[command(flatten)]
2605 live: ViewPlanArgs,
2606 },
2607 /// Change a view's explicit sharing/visibility scope metadata
2608 ///
2609 /// Scope is metadata: `user` scope stores an owner/shared-with label and
2610 /// grants no permission and creates no account (C-003/C-009).
2611 Share {
2612 /// Stable view id, or its display name
2613 reference: String,
2614 #[command(flatten)]
2615 scope: ViewScopeArgs,
2616 },
2617}
2618
2619/// The explicit scope flags shared by `create` and `share`.
2620///
2621/// One flatten per verb rather than two hand-maintained copies, so a new scope
2622/// field cannot be added to one and forgotten on the other.
2623#[derive(Args, Debug, Clone, PartialEq, Eq)]
2624pub struct ViewScopeArgs {
2625 /// Scope family: `repository` (the default) | `project` | `user`
2626 #[arg(long, value_name = "repository|project|user")]
2627 pub scope: Option<String>,
2628 /// Project id, for `--scope project`
2629 ///
2630 /// Resolved through the project registry; a project this repository does
2631 /// not know is refused by the registry, not by this flag.
2632 #[arg(long, value_name = "PROJECT")]
2633 pub project: Option<String>,
2634 /// Owner identity, for `--scope user` (metadata only — not an account)
2635 #[arg(long, value_name = "IDENTITY")]
2636 pub owner: Option<String>,
2637 /// Comma-separated shared-with identities, for `--scope user`
2638 #[arg(long, value_name = "LIST")]
2639 pub shared_with: Option<String>,
2640}
2641
2642/// The plan-shaping flags shared by `create`, `update`, and `status`.
2643///
2644/// The grammar is the **existing** one: `--filter` is the token query
2645/// `tix list --filter` accepts, `--sort`/`--group` are that grammar's control
2646/// tokens, and the visibility pair mirrors the board's `hidden_columns` /
2647/// `show_mismatch`. Nothing here parses a second query language (C-004).
2648#[derive(Args, Debug, Clone, PartialEq, Eq)]
2649pub struct ViewPlanArgs {
2650 /// Existing token query (`status:todo`, `label:urgent`, `category:done`, …)
2651 #[arg(long, value_name = "QUERY")]
2652 pub filter: Option<String>,
2653 /// Sort key (`created`, `due`, `start`, `reviewer`, `cycle`, `milestone`,
2654 /// `status`, `priority`)
2655 #[arg(long, value_name = "KEY")]
2656 pub sort: Option<String>,
2657 /// Group key (`cycle` | `milestone`)
2658 #[arg(long, value_name = "KEY")]
2659 pub group: Option<String>,
2660 /// Board columns to hide when the view is applied (comma-separated)
2661 ///
2662 /// Giving this **or** `--show-mismatch` makes visibility an absolute
2663 /// snapshot; omitting both means the view inherits the live board's
2664 /// visibility.
2665 #[arg(long, value_name = "LIST")]
2666 pub hide_columns: Option<String>,
2667 /// Show the computed "Mismatched" column when applied (`true` | `false`)
2668 #[arg(long, value_name = "true|false", num_args = 1, action = clap::ArgAction::Set)]
2669 pub show_mismatch: Option<bool>,
2670}
2671
2672impl ViewPlanArgs {
2673 /// Whether the caller named **any** plan field.
2674 ///
2675 /// The question `update` asks: no field named means "leave the stored plan
2676 /// alone", so a rename cannot silently wipe it.
2677 #[must_use]
2678 pub const fn is_any(&self) -> bool {
2679 self.filter.is_some()
2680 || self.sort.is_some()
2681 || self.group.is_some()
2682 || self.hide_columns.is_some()
2683 || self.show_mismatch.is_some()
2684 }
2685}
2686
2687/// `tix bulk <action>` — four actions, and each carries **its own** flags.
2688///
2689/// The flags are on the subcommand rather than on the parent, which is what makes the
2690/// surface unambiguous by construction rather than by a runtime check:
2691/// `tix bulk archive --priority high` is a **clap usage error**, not a command that
2692/// accepts a field flag and ignores it. `edit` is the only action with field flags,
2693/// `link`/`unlink` are the only ones with `--target`, and `archive` takes neither — so
2694/// there is no spelling of `tix bulk` that a flag can apply to by accident.
2695///
2696/// `archive` has no `--type`: archiving is not a relationship, and offering a kind
2697/// would suggest it is one.
2698/// The flags every `tix bulk` action carries: which issues, and whether to write.
2699///
2700/// Flattened into **each** action rather than held on `Command::Bulk`, because clap
2701/// parses a parent's flags *before* its subcommand — so a parent would spell the
2702/// contract's `tix bulk edit --select <...>` as `tix bulk --select <...> edit`, which
2703/// is a different surface from the one the contract names. Four `#[command(flatten)]`
2704/// of one struct gives the contract's spelling and one vocabulary, so a new flag
2705/// cannot be added to one action and forgotten on the other three.
2706#[derive(Args, Debug, Clone, PartialEq, Eq)]
2707pub struct BulkSelection {
2708 /// Comma-separated issue selectors: number, filename fragment or unique title
2709 /// fragment
2710 #[arg(long, value_name = "LIST")]
2711 pub select: Option<String>,
2712 /// A `--filter` query, in the existing filter grammar
2713 #[arg(long, value_name = "QUERY")]
2714 pub filter: Option<String>,
2715 /// A `--search` query, in the existing search grammar
2716 #[arg(long, value_name = "QUERY")]
2717 pub search: Option<String>,
2718 /// Which issues are in play: `active`, `active+archived` or `archived`
2719 #[arg(long, value_name = "SCOPE")]
2720 pub scope: Option<String>,
2721 /// Write the batch. Omit for a preview, which is the default.
2722 #[arg(long)]
2723 pub apply: bool,
2724}
2725
2726impl BulkAction {
2727 /// The shared selection flags, whichever action carries them.
2728 ///
2729 /// One accessor for four flattened fields, so the routing code reads the flags
2730 /// once and a new action cannot forget to pass them.
2731 pub fn selection(&self) -> &BulkSelection {
2732 match self {
2733 Self::Edit { selection, .. }
2734 | Self::Link { selection, .. }
2735 | Self::Unlink { selection, .. }
2736 | Self::Archive { selection } => selection,
2737 }
2738 }
2739
2740 /// Whether `--apply` was given. A preview is the default, so this is a positive
2741 /// question rather than "is this not a preview".
2742 pub fn is_apply(&self) -> bool {
2743 self.selection().apply
2744 }
2745}
2746
2747#[derive(Subcommand, Debug, Clone, PartialEq, Eq)]
2748pub enum BulkAction {
2749 /// Apply a typed field patch to every selected issue
2750 Edit {
2751 #[command(flatten)]
2752 selection: BulkSelection,
2753 /// Target column
2754 #[arg(long, value_name = "COLUMN")]
2755 status: Option<String>,
2756 /// `low`, `medium`, `high`, or `` for unset
2757 #[arg(long, value_name = "PRIORITY")]
2758 priority: Option<String>,
2759 /// Bare handle or full identity
2760 #[arg(long, value_name = "ASSIGNEE")]
2761 assignee: Option<String>,
2762 /// Comma-separated labels
2763 #[arg(long, value_name = "LABELS")]
2764 labels: Option<String>,
2765 /// `YYYY-MM-DD`
2766 #[arg(long, value_name = "DATE")]
2767 start_date: Option<String>,
2768 /// `YYYY-MM-DD`
2769 #[arg(long, value_name = "DATE")]
2770 due_date: Option<String>,
2771 /// Free-form estimate
2772 #[arg(long, value_name = "ESTIMATE")]
2773 estimate: Option<String>,
2774 /// Comma-separated cycle names
2775 #[arg(long, value_name = "CYCLES")]
2776 cycles: Option<String>,
2777 /// Comma-separated milestone names
2778 #[arg(long, value_name = "MILESTONES")]
2779 milestones: Option<String>,
2780 /// Empty a field. Takes one of the clearable field names.
2781 #[arg(long, value_name = "FIELD")]
2782 clear: Option<String>,
2783 },
2784 /// Link every selected issue to `--target`
2785 Link {
2786 #[command(flatten)]
2787 selection: BulkSelection,
2788 /// The other end of the relationship
2789 #[arg(long, value_name = "SELECTOR")]
2790 target: String,
2791 /// Relationship kind: `blocks`, `blocked_by`, `duplicates`, `related`
2792 #[arg(long, value_name = "KIND")]
2793 r#type: Option<String>,
2794 },
2795 /// Unlink every selected issue from `--target`
2796 Unlink {
2797 #[command(flatten)]
2798 selection: BulkSelection,
2799 /// The other end of the relationship
2800 #[arg(long, value_name = "SELECTOR")]
2801 target: String,
2802 /// Relationship kind: `blocks`, `blocked_by`, `duplicates`, `related`
2803 #[arg(long, value_name = "KIND")]
2804 r#type: Option<String>,
2805 },
2806 /// Ask the archive service to archive every selected issue
2807 Archive {
2808 #[command(flatten)]
2809 selection: BulkSelection,
2810 },
2811}
2812
2813/// The `tix mentions` verbs.
2814///
2815/// `list` and `show` are read-only. `read` is the one mutating answer; `ignore` is
2816/// deliberately the **absence** of a write, which is what makes the mention show
2817/// again the next time the issue is opened.
2818#[derive(Subcommand)]
2819pub enum MentionsAction {
2820 /// List the mentions pending for you
2821 List,
2822 /// Print one mention in full
2823 Show {
2824 /// Mention id, as shown by `tix mentions list`
2825 id: String,
2826 },
2827 /// Mark a mention read, so it is never shown again
2828 Read {
2829 /// Mention id, as shown by `tix mentions list`
2830 id: String,
2831 },
2832 /// Leave a mention pending (writes nothing; it shows again later)
2833 Ignore {
2834 /// Mention id, as shown by `tix mentions list`
2835 id: String,
2836 },
2837 /// Print where a mention sits in its issue
2838 Jump {
2839 /// Mention id, as shown by `tix mentions list`
2840 id: String,
2841 },
2842}
2843
2844/// The `tix analytics` subcommands (mission `burndown-scope-analytics-01M3C9J1`, WP05).
2845///
2846/// A thin adapter over the shared analytics DTOs. The daily fold, the unit conversion
2847/// and the projection live in `crate::analytics`, and the versioned chart document is
2848/// WP04's (`crate::commands::export::chart_document`), which every verb here calls rather
2849/// than re-composing. This enum owns only the spelling of the surface: the scope, the
2850/// period and the mode/unit are always explicit, and no flag here can change an issue, a
2851/// cycle date, a milestone or a snapshot.
2852#[derive(Debug, Clone, Subcommand)]
2853pub enum AnalyticsCommand {
2854 /// Print the daily remaining/completed series for one cycle or milestone
2855 ///
2856 /// A tab-separated table with a `date` column followed by the point's own fields —
2857 /// `scope_total`, `remaining`, `completed`, `scope_added`, `completions` — and the
2858 /// day's `coverage`. A value the record cannot support is `-`, never a zero, and a
2859 /// day with no recorded history is `unknown` or `coverage_limited`. The scope,
2860 /// period, mode, unit, formula version and coverage tally print above the header
2861 /// row, and the findings render to stderr with their remedies.
2862 Burndown {
2863 #[command(flatten)]
2864 args: AnalyticsScopeArgs,
2865 },
2866 /// Print the read-only end-date forecast over one cycle or milestone
2867 ///
2868 /// The planning end date is the scope's own (`--to` overrides it) and `--as-of` is
2869 /// the caller's day, never a clock. A forecast the record cannot answer prints its
2870 /// `status` (`insufficient_data` or `unknown`) with `-` for every value it could not
2871 /// derive — no completion date is ever invented, and no planning date or issue
2872 /// changes.
2873 Projection {
2874 #[command(flatten)]
2875 args: AnalyticsScopeArgs,
2876 /// The forecast origin, a `YYYY-MM-DD` calendar date
2877 #[arg(long, value_name = "YYYY-MM-DD")]
2878 as_of: String,
2879 },
2880 /// Write the deterministic local chart document (WP04's target)
2881 ///
2882 /// The document goes to stdout, or to `--output` through the atomic export writer the
2883 /// other local targets use. `--mode` is required: the document states the mode it was
2884 /// folded in, so a default would be a guess about which arithmetic produced the
2885 /// numbers. `--as-of` asks the same document for a forecast. The remote
2886 /// `gitlab`/`linear` namespaces are never consulted and cannot collide with a chart
2887 /// document.
2888 Export {
2889 #[command(flatten)]
2890 args: AnalyticsScopeArgs,
2891 /// Ask the document for a forecast at this origin, a `YYYY-MM-DD` calendar date
2892 #[arg(long, value_name = "YYYY-MM-DD")]
2893 as_of: Option<String>,
2894 /// Write the document to this path instead of stdout
2895 #[arg(long, value_name = "PATH")]
2896 output: Option<String>,
2897 },
2898}
2899
2900/// The scope, period, mode and unit flags every `tix analytics` verb shares.
2901///
2902/// The period is the scope's own planning data unless a bound is named here, and the mode
2903/// is explicit rather than inferred: an estimate valuation is only drawn through the
2904/// project's configured unit conversion, so a mode the record cannot support is refused
2905/// instead of silently falling back to a count.
2906#[derive(Debug, Clone, Args)]
2907pub struct AnalyticsScopeArgs {
2908 /// Scope kind: `cycle` or `milestone`
2909 #[arg(long, value_name = "cycle|milestone")]
2910 pub scope: String,
2911 /// The scope's planning identity: a cycle name or stable id, or a milestone name
2912 #[arg(long, value_name = "ID")]
2913 pub id: String,
2914 /// The series' inclusive start, `YYYY-MM-DD`; absent means the scope's own start
2915 #[arg(long, value_name = "YYYY-MM-DD")]
2916 pub from: Option<String>,
2917 /// The series' inclusive end, `YYYY-MM-DD`; absent means the scope's own planning end
2918 #[arg(long, value_name = "YYYY-MM-DD")]
2919 pub to: Option<String>,
2920 /// How the series counts: `count` (issue counts, the default) or `estimate`
2921 ///
2922 /// `estimate` requires `--unit` and a unit the project declares a conversion for;
2923 /// a missing or incompatible unit is refused with the shared remedy, never guessed.
2924 #[arg(long, value_name = "count|estimate")]
2925 pub mode: Option<String>,
2926 /// The unit estimate mode counts in; count mode ignores it (an issue count is unitless)
2927 #[arg(long, value_name = "UNIT")]
2928 pub unit: Option<String>,
2929}
2930
2931/// Process entry: route a parsed [`Cli`] through config resolution, store
2932/// opening and dispatch, then return the exit code for `main` to exit with.
2933pub fn run(cli: Cli) -> u8 {
2934 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
2935 match &cli.command {
2936 Some(Command::Lint { no_foreign }) => run_lint(&cwd, *no_foreign),
2937 Some(Command::Mcp) => match run_mcp(&cwd) {
2938 Ok(()) => ExitCode::Success as u8,
2939 Err(e) => {
2940 eprintln!("{}", crate::error::render_error(&e));
2941 exit_code_for(&e) as u8
2942 }
2943 },
2944 Some(Command::Render) => match run_render(&cwd) {
2945 Ok(()) => ExitCode::Success as u8,
2946 Err(e) => {
2947 eprintln!("{}", crate::error::render_error(&e));
2948 exit_code_for(&e) as u8
2949 }
2950 },
2951 // A bulk batch is the one command whose **status** is a result rather than an
2952 // error: a partial batch completes and reports non-zero, with every outcome
2953 // retained (`contracts/cli-surface.md`). So it is routed here, where the exit
2954 // code is decided, and not through `commands::dispatch`, whose arms are all
2955 // `Result<()>`.
2956 Some(Command::Bulk { action }) => {
2957 let result = resolve(&cwd).and_then(|(_, store)| {
2958 commands::bulk::run(
2959 &store,
2960 action,
2961 action.selection(),
2962 if action.is_apply() {
2963 commands::bulk::Mode::Apply
2964 } else {
2965 commands::bulk::Mode::Preview
2966 },
2967 )
2968 });
2969 match result {
2970 Ok(code) => code as u8,
2971 Err(e) => {
2972 eprintln!("{}", crate::error::render_error(&e));
2973 exit_code_for(&e) as u8
2974 }
2975 }
2976 }
2977 // `tix triage` shares bulk's exit-code shape for the same reason: a batch
2978 // that partly succeeded is a *completed* command with a status, not an
2979 // error, so `--apply` can print one outcome per selected issue and still
2980 // exit non-zero (T026). It is routed here rather than through
2981 // `commands::dispatch`, whose arms are all `Result<()>`.
2982 Some(Command::Triage { action }) => {
2983 let result = resolve(&cwd).and_then(|(_, store)| commands::triage::run(&store, action));
2984 match result {
2985 Ok(code) => code as u8,
2986 Err(e) => {
2987 eprintln!("{}", crate::error::render_error(&e));
2988 exit_code_for(&e) as u8
2989 }
2990 }
2991 }
2992 // `tix archive`/`tix restore` share the batch exit-code shape: every
2993 // selector is processed independently, a failure on one does not roll back
2994 // another, and the process still exits non-zero if any failed. Routed here
2995 // for the same reason `tix bulk` and `tix triage` are.
2996 Some(Command::Archive { issues }) => {
2997 route_archive(&cwd, commands::archive::Action::Archive, issues)
2998 }
2999 Some(Command::Restore { issues }) => {
3000 route_archive(&cwd, commands::archive::Action::Restore, issues)
3001 }
3002 // `tix project` needs a **mutable** `Config` — an accepted create/edit/delete
3003 // *is* `tix.toml` changing — which `commands::dispatch` (whose arms all borrow
3004 // `&Config`) cannot express, exactly as `tix wip` and `tix view <subcommand>`
3005 // do. Its batch verbs (`add`, `remove`, `bulk-add`) also carry a **status**
3006 // rather than an error: each selector is independent and a partly-successful
3007 // run exits non-zero. Both properties are why it is routed here, where the
3008 // config is owned and the exit code is decided.
3009 Some(Command::Project { command }) => {
3010 let result = resolve(&cwd).and_then(|(mut config, store)| {
3011 commands::project::run(&mut config, &store, command)
3012 });
3013 match result {
3014 Ok(code) => code as u8,
3015 Err(e) => {
3016 eprintln!("{}", crate::error::render_error(&e));
3017 exit_code_for(&e) as u8
3018 }
3019 }
3020 }
3021 // `tix epic` shares `tix project`'s two properties exactly: its lifecycle
3022 // verbs (`create`/`edit`/`delete`) mutate `tix.toml`, so it needs the
3023 // **mutable** `Config` only `run` owns, and its batch verbs
3024 // (`add`/`remove`/`bulk-add`) carry a **status** rather than an error,
3025 // because each selector is independent and a partly-successful run exits
3026 // non-zero. Both are why it is routed here.
3027 Some(Command::Epic { command }) => {
3028 let result = resolve(&cwd)
3029 .and_then(|(mut config, store)| commands::epic::run(&mut config, &store, command));
3030 match result {
3031 Ok(code) => code as u8,
3032 Err(e) => {
3033 eprintln!("{}", crate::error::render_error(&e));
3034 exit_code_for(&e) as u8
3035 }
3036 }
3037 }
3038 // `tix initiative` shares `tix project`'s property: its lifecycle verbs
3039 // (`create`/`edit`/`delete`) and its association verbs mutate `tix.toml`, so
3040 // it needs the **mutable** `Config` only `run` owns, and an accepted change
3041 // is written through the registry's atomic boundary. `tix roadmap` is
3042 // read-only and fits `commands::dispatch`, so it is routed there.
3043 Some(Command::Initiative { command }) => {
3044 let result = resolve(&cwd).and_then(|(mut config, store)| {
3045 commands::roadmap::run_initiative(&mut config, &store, command)
3046 });
3047 match result {
3048 Ok(code) => code as u8,
3049 Err(e) => {
3050 eprintln!("{}", crate::error::render_error(&e));
3051 exit_code_for(&e) as u8
3052 }
3053 }
3054 }
3055 // Only `tix cycle schedule …` needs the **mutable** config: an accepted
3056 // `schedule create|edit|delete` *is* `tix.toml` changing, and
3057 // `commands::dispatch` (whose arms all borrow `&Config`) cannot provide it.
3058 // The frozen legacy verbs are deliberately **not** intercepted: they fall
3059 // through to `run_subcommand`, so they keep `commands::dispatch`'s legacy
3060 // arm *and* the rollover advisory that every `tix cycle …` emitted at the
3061 // baseline — behavior WP06 must not change.
3062 Some(Command::Cycle {
3063 command: CycleCommand::Schedule { command },
3064 }) => {
3065 let result = resolve(&cwd).and_then(|(mut config, store)| {
3066 commands::cycle::run_schedule(&mut config, &store, command)
3067 });
3068 match result {
3069 Ok(()) => ExitCode::Success as u8,
3070 Err(e) => {
3071 eprintln!("{}", crate::error::render_error(&e));
3072 exit_code_for(&e) as u8
3073 }
3074 }
3075 }
3076 // `tix capacity set|delete` mutate `tix.toml`, so they need the **mutable**
3077 // `Config` only the process entry owns — the same reason `tix wip` is routed
3078 // here. The read verbs (`list`/`show`/`calculate`/`snapshot`) ride along so
3079 // the family has one boundary, and none of them writes.
3080 Some(Command::Capacity { command }) => {
3081 let result = resolve(&cwd).and_then(|(mut config, store)| {
3082 commands::capacity::run(&mut config, &store, command)
3083 });
3084 match result {
3085 Ok(()) => ExitCode::Success as u8,
3086 Err(e) => {
3087 eprintln!("{}", crate::error::render_error(&e));
3088 exit_code_for(&e) as u8
3089 }
3090 }
3091 }
3092 // `tix template`: management mutates `tix.toml`, so it needs a **mutable**
3093 // config and the path it came from — neither of which `commands::dispatch`
3094 // carries. Routed here, before the store is opened, exactly like the TUI
3095 // and `tix mcp`.
3096 Some(Command::Template { action }) => match commands::templates::run(&cwd, action) {
3097 Ok(()) => ExitCode::Success as u8,
3098 Err(e) => {
3099 eprintln!("{}", crate::error::render_error(&e));
3100 exit_code_for(&e) as u8
3101 }
3102 },
3103 _ => {
3104 let result = match &cli.command {
3105 None => run_tui(&cwd),
3106 Some(Command::New) => run_tui_new(&cwd),
3107 Some(Command::Init) => commands::init::run(&cwd),
3108 Some(Command::InitAgents) => {
3109 let root = Config::project_root(&cwd).unwrap_or(cwd.clone());
3110 commands::agents::run(&root).map(|_| ())
3111 }
3112 Some(cmd) => run_subcommand(&cwd, cmd),
3113 };
3114 match result {
3115 Ok(()) => ExitCode::Success as u8,
3116 Err(e) => {
3117 eprintln!("{}", crate::error::render_error(&e));
3118 exit_code_for(&e) as u8
3119 }
3120 }
3121 }
3122 }
3123}
3124
3125/// Process one `tix archive`/`tix restore` batch and map it to an exit code.
3126///
3127/// Each selector is independent: an outcome is printed per item and a failure on
3128/// one item neither aborts the rest nor rolls back a successful rename. The
3129/// process exits with the first failure's code, or `0` when every item succeeded.
3130fn route_archive(cwd: &Path, action: commands::archive::Action, issues: &[String]) -> u8 {
3131 match resolve(cwd).and_then(|(_, store)| commands::archive::run(&store, action, issues)) {
3132 Ok(code) => code as u8,
3133 Err(e) => {
3134 eprintln!("{}", crate::error::render_error(&e));
3135 exit_code_for(&e) as u8
3136 }
3137 }
3138}
3139
3140/// `tix lint` owns its own boundary: its exit-code table (`0` healthy, `1`
3141/// findings, `2` usage/internal) does not match the generic
3142/// [`exit_code_for`] mapping, which would collapse internal errors into `1`.
3143fn run_lint(cwd: &Path, no_foreign: bool) -> u8 {
3144 let (config, store) = match resolve(cwd) {
3145 Ok(pair) => pair,
3146 Err(e) => {
3147 eprintln!("{}", crate::error::render_error(&e));
3148 return 2;
3149 }
3150 };
3151 match commands::lint::run_with(&config, &store, !no_foreign) {
3152 Ok(true) => 1,
3153 Ok(false) => 0,
3154 Err(e) => {
3155 eprintln!("{}", crate::error::render_error(&e));
3156 2
3157 }
3158 }
3159}
3160
3161/// `tix` with no subcommand: launch the TUI, or fail with the contract's
3162/// exact message when no `tix.toml` is reachable (exit 1).
3163///
3164/// tix #1632: on a terminal, a missing `tix.toml` is offered a one-key escape —
3165/// create the default configuration and continue into the TUI — rather than
3166/// being turned away with a message naming a command to run. A declined prompt,
3167/// and a stdin that cannot be asked (not a terminal), both keep the exact
3168/// contract message.
3169fn run_tui(cwd: &Path) -> Result<()> {
3170 let config = tui_config(cwd)?;
3171 crate::app::run(&config)
3172}
3173
3174/// `tix new`: the same session, opened on the create-issue form instead of the
3175/// board. Same repository resolution as [`run_tui`], so a missing `tix.toml`
3176/// fails with the same message rather than opening a form that could not be
3177/// submitted.
3178fn run_tui_new(cwd: &Path) -> Result<()> {
3179 let config = tui_config(cwd)?;
3180 crate::app::run_new_issue(&config)
3181}
3182
3183/// Repository resolution for the TUI commands, offering to scaffold a repository
3184/// first when none is reachable (tix #1632).
3185fn tui_config(cwd: &Path) -> Result<Config> {
3186 if !offer_first_run_init(cwd)? {
3187 return Err(TixError::Config(
3188 "no tix.toml found — run \"tix init\" first".into(),
3189 ));
3190 }
3191 repo_config(cwd)
3192}
3193
3194/// Ask, once, whether a missing `tix.toml` should be created with the defaults.
3195///
3196/// Returns `true` when the caller may proceed (a repository was already there, or
3197/// the user said yes to `tix init` default values). Returns `false` when there is
3198/// no repository and the user declined — or could not be asked at all, since a
3199/// non-terminal stdin cannot consent, and silently writing a project into a pipe's
3200/// directory would be the wrong answer.
3201fn offer_first_run_init(cwd: &Path) -> Result<bool> {
3202 use std::io::IsTerminal;
3203
3204 if Config::project_root(cwd).is_some() {
3205 return Ok(true);
3206 }
3207 if !std::io::stdin().is_terminal() {
3208 return Ok(false);
3209 }
3210 eprint!("tix: no tix.toml found — create one with default settings? [Y/n] ");
3211 let mut line = String::new();
3212 std::io::stdin()
3213 .read_line(&mut line)
3214 .map_err(TixError::Io)?;
3215 let answer = line.trim();
3216 if answer.is_empty() || answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") {
3217 commands::init::run(cwd)?;
3218 return Ok(true);
3219 }
3220 eprintln!("tix: aborted — run \"tix init\" when you are ready");
3221 Ok(false)
3222}
3223
3224/// Strict repository resolution shared by the TUI and `tix mcp` (FR-015):
3225/// upward `tix.toml` discovery, failing with the contract's exact `no
3226/// tix.toml found` guidance (exit 1) when no repository is reachable —
3227/// never a guessed default store. This is the "CLI hot path" that
3228/// `contracts/mcp-protocol.md` §7 requires `tix mcp` to reuse.
3229fn repo_config(cwd: &Path) -> Result<Config> {
3230 let root = Config::project_root(cwd)
3231 .ok_or_else(|| TixError::Config("no tix.toml found — run \"tix init\" first".into()))?;
3232 let mut config = Config::load(&root)?;
3233 // `tags_dir`, `[repos]` and `parent_repo` are relative to the project root
3234 // (tix #1529/#1530), so the TUI can spawn tix in a child from anywhere.
3235 config.resolve_repo_paths(&root);
3236 Ok(config)
3237}
3238
3239/// `tix mcp`: strict repository resolution (same hot path as `tix` with no
3240/// subcommand), then serve the MCP protocol synchronously over stdio. Outside
3241/// a repository the process fails with exit 1 BEFORE reading any protocol
3242/// bytes (FR-016, `contracts/mcp-protocol.md` §7).
3243fn run_mcp(cwd: &Path) -> Result<()> {
3244 let config = repo_config(cwd)?;
3245 let store = Store::open(&config)?;
3246 // Absolutize milestones against the same project root as issues_dir so the
3247 // MCP `create_issue`/`update_issue` planning validators and the CLI resolve
3248 // milestone files to the identical directory (parity cross-checker).
3249 let mut config = config;
3250 if config.milestones_dir().is_relative() {
3251 let root = Config::project_root(cwd).unwrap_or_else(|| cwd.to_path_buf());
3252 let configured = config.milestones_dir();
3253 config.milestones_dir = Some(root.join(configured));
3254 }
3255 crate::mcp::serve(&config, &store)
3256}
3257
3258/// `tix render`: strict repository resolution like `tix`/`tix mcp` so the
3259/// command fails with the same no-config guidance outside a repository.
3260fn run_render(cwd: &Path) -> Result<()> {
3261 let config = repo_config(cwd)?;
3262 let store = Store::open(&config)?;
3263 commands::dispatch(&Command::Render, &config, &store)
3264}
3265
3266/// Config resolution for subcommands: defaults when no `tix.toml` exists
3267/// (relative to the project root via upward walk), with a one-time stderr
3268/// notice. Only explicit per-command requirements fail hard.
3269fn resolve(cwd: &Path) -> Result<(Config, Store)> {
3270 let root = Config::project_root(cwd);
3271 let mut config = match &root {
3272 Some(r) => Config::load(r)?,
3273 None => Config::default(),
3274 };
3275 // Resolve a relative issues_dir against the project root (or cwd when
3276 // unconfigured) so subcommands behave identically from any subdirectory.
3277 if config.issues_dir.is_relative() {
3278 let base = root.as_deref().unwrap_or(cwd);
3279 config.issues_dir = base.join(&config.issues_dir);
3280 }
3281 // Milestones resolve against the same base as issues_dir: the planning
3282 // validators (create/edit) and `lint` must agree on where milestone
3283 // registry files live regardless of the invocation directory.
3284 if config.milestones_dir().is_relative() {
3285 let base = root.as_deref().unwrap_or(cwd);
3286 let configured = config.milestones_dir();
3287 config.milestones_dir = Some(base.join(configured));
3288 }
3289 // `tags_dir`, `[repos]` and `parent_repo` resolve against the same base.
3290 config.resolve_repo_paths(root.as_deref().unwrap_or(cwd));
3291 if root.is_none() {
3292 eprintln!("tix: no tix.toml found (run 'tix init' to create one)");
3293 }
3294 let store = Store::open(&config)?;
3295 Ok((config, store))
3296}
3297
3298fn run_subcommand(cwd: &Path, cmd: &Command) -> Result<()> {
3299 let (mut config, store) = resolve(cwd)?;
3300 // `tix view <subcommand>` is the one command tree that needs the config
3301 // **mutably**: an accepted view mutation *is* `config.views` changing, and
3302 // `commands::dispatch` (whose every arm borrows `&Config`) cannot express
3303 // that. Routed here, at the process boundary where the config is owned, for
3304 // the same reason `tix bulk` and `tix triage` are routed in `run`.
3305 if let Command::View {
3306 action: Some(action),
3307 ..
3308 } = cmd
3309 {
3310 return commands::views::run(&mut config, &store, action);
3311 }
3312 // `tix wip set/unset/unit` mutate `tix.toml`, so they are routed here for the
3313 // same reason the view mutations are: `commands::dispatch`'s arms borrow
3314 // `&Config`, and an accepted WIP edit *is* the configuration changing.
3315 if let Command::Wip { action } = cmd {
3316 return commands::wip::run(&mut config, &store, action);
3317 }
3318 if !matches!(cmd, Command::Rollover { .. }) {
3319 let planner = crate::planning::Planner::from_config(config.clone(), cwd.to_path_buf());
3320 let plan = planner.rollover_plan(&store, &crate::model::today());
3321 if let Some(first) = plan.first() {
3322 eprintln!(
3323 "hint: rollover of {} issue(s) from \"{}\" to \"{}\" is suggested; run 'tix rollover' to apply",
3324 first.issues.len(),
3325 first.from_cycle,
3326 first.to_cycle
3327 );
3328 }
3329 }
3330 commands::dispatch(cmd, &config, &store)
3331}