Skip to main content

ignition_cli/
cli.rs

1//! CLI definition: the five global args (defined exactly once, here) and the
2//! subcommand enum.
3//!
4//! Placement rules (Phase 1 research, Pattern 1):
5//! - Globals set the `global` arg attribute so they propagate to every
6//!   subcommand; subcommand structs never redeclare them.
7//! - Never mark a global arg `required` (clap rejects required globals).
8//! - Flags-only, zero interactive prompts — ever (research anti-pattern:
9//!   "Prompting ever").
10
11use std::path::PathBuf;
12
13use clap::{ArgAction, Parser, Subcommand};
14
15#[derive(Debug, Parser)]
16#[command(
17    name = "ign",
18    version,
19    propagate_version = true,
20    about = "Operate Ignition 8.3+ gateways from the terminal"
21)]
22pub struct Cli {
23    /// Gateway profile to use (default: active profile in config)
24    #[arg(long, global = true, value_name = "NAME")]
25    pub profile: Option<String>,
26
27    /// Machine-readable JSON output (stable field names)
28    #[arg(long, global = true, action = ArgAction::SetTrue)]
29    pub json: bool,
30
31    /// One-line compact JSON (implies --json)
32    #[arg(long, global = true, action = ArgAction::SetTrue)]
33    pub compact: bool,
34
35    /// Non-interactive confirmation for destructive operations
36    #[arg(long, short = 'y', global = true, action = ArgAction::SetTrue)]
37    pub yes: bool,
38
39    /// Increase diagnostics (-vv for HTTP trace) to stderr
40    #[arg(long, short = 'v', global = true, action = ArgAction::Count)]
41    pub verbose: u8,
42
43    #[command(subcommand)]
44    pub command: Commands,
45}
46
47#[derive(Debug, Subcommand)]
48pub enum Commands {
49    /// Print version information (CLI always; gateway check when a profile resolves)
50    Version,
51
52    /// Generate shell completions (bash, zsh, fish, …) — the one stdout exception
53    #[command(arg_required_else_help = true)]
54    Completions {
55        /// Shell to generate completions for
56        #[arg(value_enum)]
57        shell: clap_complete::aot::Shell,
58    },
59
60    /// Gateway status: identity, platform, uptime, license (incl. trial countdown)
61    Status,
62
63    /// List gateway modules (healthy by default)
64    Modules {
65        /// Show quarantined modules instead of healthy ones
66        #[arg(long)]
67        quarantined: bool,
68    },
69
70    /// Gateway performance metrics (current gauges + thread counts)
71    Metrics {
72        /// Include historic chart datapoints
73        #[arg(long)]
74        history: bool,
75    },
76
77    /// List gateway sessions (designers, Perspective, Vision) — or
78    /// terminate one via the `terminate` subcommand
79    Sessions(SessionsArgs),
80
81    /// List database/OPC connections with healthcheck status as reported
82    Connections {
83        /// Filter to one connection family (default: both)
84        #[arg(long, value_enum)]
85        r#type: Option<ConnectionType>,
86    },
87
88    /// Query, tail, and download gateway logs; manage logger levels
89    Logs(LogsArgs),
90
91    /// Restart the gateway — destructive, refused without --yes;
92    /// --wait polls until RUNNING
93    Restart {
94        /// Wait for the gateway to return to RUNNING (POST, then poll
95        /// /StatusPing)
96        #[arg(long)]
97        wait: bool,
98        /// Wait budget in seconds (default 300)
99        #[arg(long, value_name = "SECS")]
100        timeout: Option<u64>,
101        /// Poll interval in seconds (default 2)
102        #[arg(long, value_name = "SECS")]
103        interval: Option<u64>,
104    },
105
106    /// Wait for a gateway state (readiness, restart completion, module)
107    #[command(arg_required_else_help = true)]
108    Wait(WaitArgs),
109
110    /// Diagnose the gateway setup: URL, liveness, commissioning, auth,
111    /// permissions, write, WebDev route, rig — exits 0 whenever the
112    /// diagnosis completes (failing checks are data)
113    Doctor {
114        /// Probe write permission (POST scan/projects — a harmless
115        /// project rescan)
116        #[arg(long)]
117        check_write: bool,
118        /// Probe one WebDev route's presence (/system/webdev/<NAME>)
119        #[arg(long, value_name = "NAME")]
120        webdev_route: Option<String>,
121    },
122
123    /// Manage gateway projects: list with inheritance info, new, copy,
124    /// rename, set (reparent), delete, export/import (ZIP)
125    #[command(arg_required_else_help = true)]
126    Project(ProjectArgs),
127
128    /// Check out a project's resources to a local tree, inspect drift,
129    /// push guarded changes — the local edit loop over the
130    /// project-export interchange (project RESOURCES only; tag values
131    /// never appear in the tree — tags remain `ign tags` verbs)
132    #[command(arg_required_else_help = true)]
133    Workspace(WorkspaceArgs),
134
135    /// Fetch a gateway resource, edit it in $EDITOR, push back
136    /// (guarded) — the kubectl-edit loop over ONE resource:
137    /// unchanged saves are clean no-ops (nothing pushed, never
138    /// prompted), a gateway changed since fetch refuses (never
139    /// force-pushed), a JSON-breaking save refuses fail-closed with
140    /// the temp tree kept (path printed). Zero stdout in every mode —
141    /// the editor owns the terminal (see README's edit section)
142    Edit(EditArgs),
143
144    /// Manage a project's individual resources: list, get, put,
145    /// delete — the surgical edit loop (change one view/script
146    /// without re-importing everything)
147    #[command(arg_required_else_help = true)]
148    Resource(ResourceArgs),
149
150    /// Manage the CLI's own WebDev routes on the gateway: deploy the
151    /// embedded bundle, verify the version handshake
152    #[command(arg_required_else_help = true)]
153    Webdev(WebdevArgs),
154
155    /// Tag operations: manage providers (native REST) and browse /
156    /// read / write tag values (the deployed WebDev routes — run
157    /// `ign webdev deploy` first)
158    #[command(arg_required_else_help = true)]
159    Tags(TagsArgs),
160
161    /// Manage a Docker compose rig: up/down/reset/status/logs/trial
162    /// (snapshot arrives in a later plan) — docker-only, no profile
163    /// needed
164    #[command(arg_required_else_help = true)]
165    Rig(RigArgs),
166
167    /// Manage gateway backups (gwbk) on any profiled gateway:
168    /// download (streamed) and restore (guarded)
169    #[command(arg_required_else_help = true)]
170    Backup(BackupArgs),
171
172    /// EAM task orchestration (Enterprise Administration Module):
173    /// history/definitions reads, guarded task create + force —
174    /// every verb honestly reports the controller-mode state gate
175    #[command(arg_required_else_help = true)]
176    Eam(EamArgs),
177
178    /// Run gateway-side Python (Jython) through the secret-gated
179    /// scriptExec route — the opt-in is STRUCTURAL (`ign webdev
180    /// deploy --with-script-exec` deploys the route + persists its
181    /// secret); there is no --yes on this verb by design
182    #[command(arg_required_else_help = true)]
183    Script(ScriptArgs),
184
185    /// Lint local project files by delegating to ignition-lint (PATH
186    /// discovery) — doctor posture: findings are DATA, exit 0
187    /// whenever the tool ran; --strict passes the tool's exit code
188    /// through for CI
189    Lint(LintArgs),
190
191    /// Raw passthrough to any gateway REST endpoint — the escape
192    /// hatch for the uncurated routes (the envelope's `data` is left
193    /// gateway-verbatim; see README's documented contract exception)
194    #[command(arg_required_else_help = true)]
195    Api(ApiArgs),
196
197    /// License inventory + trial state — the morning-check read
198    /// (trial companion merged into one command)
199    #[command(arg_required_else_help = true)]
200    License(LicenseArgs),
201
202    /// Gateway redundancy status — the morning-check read
203    #[command(arg_required_else_help = true)]
204    Redundancy(RedundancyArgs),
205
206    /// Gateway Area Network (GAN) overview — the morning-check read
207    #[command(arg_required_else_help = true)]
208    Gan(GanArgs),
209
210    /// Diagnostics support-bundle operations: generate, check
211    /// status, download (streamed), wait — the support-bundle slice
212    #[command(arg_required_else_help = true)]
213    Diagnostics(DiagnosticsArgs),
214
215    /// Manage gateway profiles
216    #[command(arg_required_else_help = true)]
217    Profile(ProfileArgs),
218
219    /// Serve the Model Context Protocol over stdio (hidden; for MCP
220    /// clients — the newline-delimited JSON-RPC 2.0 stream IS the
221    /// stdout product; the routes.rs OutOfBand row lands atomically
222    /// with this command, 08-06 contract)
223    #[command(hide = true)]
224    Mcp(McpArgs),
225
226    /// Serve LSP over stdio (hidden; for ignition-nvim — the
227    /// Content-Length-framed LSP stream IS the stdout product; the
228    /// routes.rs OutOfBand row lands atomically with this command,
229    /// 08-06 contract)
230    #[command(hide = true)]
231    Lsp,
232
233    /// Interactive TUI cockpit
234    #[cfg(feature = "tui")]
235    Tui,
236}
237
238/// Hidden args for `ign mcp serve` (14-01). `serve` is a RESTRICTED
239/// POSITIONAL — not a subcommand — so the clap walk (tui_coverage.rs)
240/// yields exactly ONE row-requiring node, `mcp` itself: an `mcp
241/// serve` subcommand leaf would need a second routes() row and extend
242/// the pinned OutOfBand set past its four-member contract (an orphan
243/// row fails the walk by design). clap's `value_parser = ["serve"]`
244/// keeps every other spelling (`ign mcp`, `ign mcp bogus`) a
245/// usage-class clap error — exit 2 BEFORE any protocol byte is
246/// written. Zero stdout-facing behavior here — the mode owns its own
247/// I/O entirely (`mcp::serve`).
248#[derive(Debug, clap::Args)]
249pub struct McpArgs {
250    /// Start the stdio MCP server
251    #[arg(value_parser = ["serve"], value_name = "SERVE")]
252    pub serve: String,
253}
254
255/// Wait targets (02-05, HLTH-11). `gateway` and `restart` poll the
256/// UNAUTHENTICATED /StatusPing (the dispatch builds a header-less
257/// client for them — waiting must work when auth is broken); `module`
258/// is an authed read.
259#[derive(Debug, clap::Args)]
260pub struct WaitArgs {
261    #[command(subcommand)]
262    pub command: WaitCmd,
263}
264
265#[derive(Debug, Subcommand)]
266pub enum WaitCmd {
267    /// Wait until the gateway reports RUNNING (unauthenticated
268    /// StatusPing — works even when auth is broken or absent)
269    Gateway {
270        /// Poll interval in seconds
271        #[arg(long, default_value_t = 2, value_name = "SECS")]
272        interval: u64,
273        /// Give up after this many seconds
274        #[arg(long, default_value_t = 120, value_name = "SECS")]
275        timeout: u64,
276    },
277    /// Wait for a restart to complete — restart-aware: shares
278    /// `restart --wait`'s semantics (non-RUNNING observed once →
279    /// RUNNING; a 5 s floor guards the all-RUNNING case)
280    Restart {
281        /// Poll interval in seconds
282        #[arg(long, default_value_t = 2, value_name = "SECS")]
283        interval: u64,
284        /// Give up after this many seconds (default 300)
285        #[arg(long, default_value_t = 300, value_name = "SECS")]
286        timeout: u64,
287    },
288    /// Wait until a module reports ACTIVE
289    Module {
290        /// Module id (see `ign modules`)
291        id: String,
292        /// Poll interval in seconds
293        #[arg(long, default_value_t = 2, value_name = "SECS")]
294        interval: u64,
295        /// Give up after this many seconds
296        #[arg(long, default_value_t = 120, value_name = "SECS")]
297        timeout: u64,
298    },
299}
300
301/// Project subcommands (03-01, PROJ-01/02). `delete` is the family's
302/// ONE destructive verb (`--yes`-guarded, exit 2 without); copy/
303/// rename/set create or relabel — never destroy — so they carry NO
304/// guard (planner decision per research).
305#[derive(Debug, clap::Args)]
306pub struct ProjectArgs {
307    #[command(subcommand)]
308    pub command: ProjectCommand,
309}
310
311#[derive(Debug, Subcommand)]
312pub enum ProjectCommand {
313    /// List every runnable project: name, title, enabled, parent,
314    /// inheritable (inheritance info from the items themselves)
315    List,
316    /// Create a project (only provided fields ride the create body)
317    New {
318        /// Project name
319        name: String,
320        /// Display title
321        #[arg(long, value_name = "TEXT")]
322        title: Option<String>,
323        /// Long description
324        #[arg(long, value_name = "TEXT")]
325        description: Option<String>,
326        /// Parent project (inheritance)
327        #[arg(long, value_name = "NAME")]
328        parent: Option<String>,
329        /// Mark this project eligible as a parent
330        #[arg(long)]
331        inheritable: bool,
332        /// Create the project disabled
333        #[arg(long)]
334        disabled: bool,
335    },
336    /// Copy a project with all its resources
337    Copy {
338        /// Source project name
339        src: String,
340        /// Destination name (must not exist)
341        dst: String,
342    },
343    /// Rename a project (native rename, not copy+delete)
344    Rename {
345        /// Current name
346        old_name: String,
347        /// New name
348        new_name: String,
349    },
350    /// Set project fields — --parent IS the inheritance move (reparent)
351    #[command(group(
352        clap::ArgGroup::new("set_fields")
353            .required(true)
354            .multiple(true)
355            .args(&["title", "description", "parent", "set_enabled", "disabled", "inheritable"])
356    ))]
357    Set {
358        /// Project name
359        name: String,
360        /// Display title
361        #[arg(long, value_name = "TEXT")]
362        title: Option<String>,
363        /// Long description
364        #[arg(long, value_name = "TEXT")]
365        description: Option<String>,
366        /// Parent project (reparent)
367        #[arg(long, value_name = "NAME")]
368        parent: Option<String>,
369        /// Enable the project
370        #[arg(long, conflicts_with = "disabled")]
371        set_enabled: bool,
372        /// Disable the project
373        #[arg(long)]
374        disabled: bool,
375        /// Whether this project may serve as a parent (true/false)
376        #[arg(long, value_name = "BOOL")]
377        inheritable: Option<bool>,
378    },
379    /// Delete a project — destructive, refused without --yes
380    Delete {
381        /// Project name
382        name: String,
383    },
384    /// Export a project as a ZIP archive (streams to disk)
385    Export {
386        /// Project name
387        name: String,
388        /// Output file (default: the gateway's Content-Disposition
389        /// name, else <name>.zip); with --decode-scripts, the output
390        /// DIRECTORY (default <name>-export/)
391        #[arg(short, long, value_name = "FILE")]
392        output: Option<PathBuf>,
393        /// Also decode embedded JSON scripts (Perspective view.json
394        /// etc.) into editable `<member>.<n>.py` sidecars beside a
395        /// copied member tree + scripts-manifest.json — the output is
396        /// a DIRECTORY (script-python members are already plain .py
397        /// and never decode; expressions pass through)
398        #[arg(long)]
399        decode_scripts: bool,
400    },
401    /// Import a project from a ZIP archive
402    Import {
403        /// Project name to import as
404        name: String,
405        /// ZIP file path, or - to read the archive from stdin; with
406        /// --encode-scripts, a decoded export DIRECTORY (re-zipped
407        /// with the sidecars spliced back before upload; stdin is
408        /// invalid in that mode)
409        #[arg(long, value_name = "PATH")]
410        file: String,
411        /// Collision policy: abort refuses when the name exists
412        /// (default); overwrite REPLACES the entire project —
413        /// destructive, requires --yes. merge is Designer-only (not a
414        /// value; the README documents why)
415        #[arg(long, value_enum, default_value_t = CollisionPolicy::Abort)]
416        collision_policy: CollisionPolicy,
417        /// --file points at a decoded export DIRECTORY (from
418        /// `export --decode-scripts`): the sidecars are spliced back
419        /// and the manifest stripped before the standard import path
420        #[arg(long)]
421        encode_scripts: bool,
422    },
423    /// Compare a project across two gateway profiles — statuses are
424    /// B-relative-to-A (`added` = in B only, `removed` = in A only,
425    /// `changed` = differing after resource.json normalization)
426    Diff {
427        /// Baseline profile (A)
428        profile_a: String,
429        /// Compared profile (B — the diff is B relative to A)
430        profile_b: String,
431        /// Project name
432        #[arg(long, value_name = "NAME")]
433        project: String,
434    },
435    /// Promote selected resources from profile A into profile B
436    /// (direction is ALWAYS A→B) — destructive on B: the whole
437    /// project is overwrite-imported, refused without --yes
438    Sync {
439        /// Source profile (A)
440        profile_a: String,
441        /// Target profile (B)
442        profile_b: String,
443        /// Project name
444        #[arg(long, value_name = "NAME")]
445        project: String,
446        /// Resource user path to promote (repeatable)
447        #[arg(long, value_name = "PATH")]
448        resource: Vec<String>,
449        /// Promote every resource the diff reports added or changed
450        /// (never removed ones — deletion needs --delete)
451        #[arg(long)]
452        all_changed: bool,
453        /// Also remove B's resources the diff reports removed
454        /// (default: upsert-only, nothing is ever deleted)
455        #[arg(long)]
456        delete: bool,
457    },
458}
459
460/// Workspace subcommands (13-07): the local edit loop. `checkout`
461/// writes the mapped tree + records `.ign-workspace.json` (the
462/// manifest IS the workspace identity — status/push read it and the
463/// user never re-types the project); `status` reports PUSH-RELATIVE
464/// drift (every row says what push would do); `push` is the guarded
465/// splice of local edits into a FRESH gateway export.
466#[derive(Debug, clap::Args)]
467pub struct WorkspaceArgs {
468    #[command(subcommand)]
469    pub command: WorkspaceCommand,
470}
471
472#[derive(Debug, Subcommand)]
473pub enum WorkspaceCommand {
474    /// Check out a project's resources to a local tree — mapped
475    /// paths + the recorded `.ign-workspace.json` manifest + an
476    /// idempotent `.gitignore` (manifest committed; codec artifacts
477    /// ignored). Read-only on the wire: one export GET, zero imports.
478    Checkout {
479        /// Project name to check out
480        project: String,
481        /// Target directory for the workspace tree (created if
482        /// absent; an unrelated non-empty directory refuses — never
483        /// clobbered)
484        target: PathBuf,
485        /// Decode embedded JSON scripts to editable .py sidecars
486        /// (nvim-editable; encodes back cleanly — an unedited
487        /// re-encode is byte-exact; `scripts-manifest.json` keys the
488        /// splice)
489        #[arg(long)]
490        decode_scripts: bool,
491    },
492    /// Report workspace drift against the gateway — states are
493    /// PUSH-RELATIVE (each row names what push would do: write /
494    /// leave / refuse); the project comes from the workspace
495    /// manifest, never re-typed. Read-only: one export GET.
496    Status {
497        /// Workspace root (default: the current directory)
498        #[arg(default_value = ".")]
499        path: PathBuf,
500    },
501    /// Push local edits to the gateway — guarded (refused without
502    /// --yes; the refusal message IS the blast-radius preview);
503    /// conflicts refuse EVEN WITH --yes (manual reconciliation);
504    /// deletions need --delete (default: reported, skipped);
505    /// untracked files are never imported; an empty selection
506    /// writes nothing.
507    Push {
508        /// Workspace root (default: the current directory)
509        #[arg(default_value = ".")]
510        path: PathBuf,
511        /// Also delete gateway members removed locally (default:
512        /// reported, skipped)
513        #[arg(long)]
514        delete: bool,
515    },
516}
517
518/// `ign edit` args (13-08): the kubectl-edit loop over ONE resource.
519/// The resource path scopes what $EDITOR opens (the whole tree still
520/// decodes for encode-back context); the push rides the
521/// project-import wire behind the ONE confirmation gate. `--yes` is
522/// the GLOBAL flag (globals-once) — never redeclared here.
523#[derive(Debug, clap::Args)]
524pub struct EditArgs {
525    /// Project containing the resource
526    pub project: String,
527    /// Resource user path to open in $EDITOR (e.g.
528    /// `ignition/script-python/e2e/scratch` — `ign resource list`
529    /// names the valid members)
530    pub resource_path: String,
531}
532
533/// Resource subcommands (03-03, PROJ-05). `delete` is the family's
534/// destructive verb (`--yes`-guarded, exit 2 without — the
535/// sessions-terminate shape); `put` is an upsert (create-or-replace
536/// ONE resource with explicit content) and stays friction-free per
537/// the planner decision — agents pass exactly what they want written.
538#[derive(Debug, clap::Args)]
539pub struct ResourceArgs {
540    #[command(subcommand)]
541    pub command: ResourceCommand,
542}
543
544#[derive(Debug, Subcommand)]
545pub enum ResourceCommand {
546    /// List a project's resources (one path per line in human mode)
547    List {
548        /// Project name
549        project: String,
550        /// Only paths under this prefix (rides the wire as the
551        /// server-side `path` filter)
552        #[arg(long, value_name = "PREFIX")]
553        prefix: Option<String>,
554    },
555    /// Read one resource: JSON pretty-printed, text raw — binary
556    /// (data.bin-class) resources refuse with exit 6
557    Get {
558        /// Project name
559        project: String,
560        /// Resource path, slashes kept (e.g.
561        /// `ignition/script-python/e2e/scratch`)
562        path: String,
563    },
564    /// Write one resource (upsert: created if absent, replaced if
565    /// present) — JSON if parseable (application/json), else UTF-8
566    /// text (text/plain); binary-looking input refuses
567    Put {
568        /// Project name
569        project: String,
570        /// Resource path
571        path: String,
572        /// File to read the content from, or `-` for stdin
573        #[arg(long, value_name = "PATH")]
574        file: String,
575    },
576    /// Delete one resource — destructive, refused without --yes
577    Delete {
578        /// Project name
579        project: String,
580        /// Resource path
581        path: String,
582    },
583}
584
585/// Webdev subcommands (05-03, WEB-01/02). Deploy is deliberately NOT
586/// `--yes`-guarded: the dedicated project (default `ign-cli`) is
587/// CLI-OWNED — born from the first deploy zip and overwrite-replaced
588/// on every deploy (replace-not-merge is the CONTRACT here; user
589/// projects are never touched — README documents). scriptExec rides
590/// only on explicit request, gated by a deploy-time generated shared
591/// secret stored in the profile config at 0600.
592#[derive(Debug, clap::Args)]
593pub struct WebdevArgs {
594    #[command(subcommand)]
595    pub command: WebdevCommand,
596}
597
598#[derive(Debug, Subcommand)]
599pub enum WebdevCommand {
600    /// Deploy the embedded route bundle into the dedicated project
601    /// (overwrite-replace — the CLI owns that project wholesale)
602    Deploy {
603        /// Target project (default ign-cli; the CLI owns it wholesale)
604        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
605        project: String,
606        /// Also deploy the secret-gated scriptExec route (a fresh
607        /// secret is generated and stored at 0600 when none exists)
608        #[arg(long)]
609        with_script_exec: bool,
610        /// Generate a FRESH scriptExec secret before deploying (any
611        /// route deployed with the old secret starts refusing)
612        #[arg(long, requires = "with_script_exec")]
613        rotate_secret: bool,
614    },
615    /// Probe every route's version handshake — a READ: exit 0
616    /// whenever the sweep completes, per-route degradation is data
617    Status {
618        /// Target project (default ign-cli)
619        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
620        project: String,
621    },
622}
623
624/// Tags args (05-04, TAGS-01..04) — the grouped-subfamily pattern:
625/// `tags provider …` nests one level deeper (the family's native
626/// REST half), browse/read/write ride the TOP level. The
627/// webdev-dependent arms carry `--project` (default `ign-cli`, the
628/// same default as deploy — the deployed routes live in that
629/// project).
630#[derive(Debug, clap::Args)]
631pub struct TagsArgs {
632    #[command(subcommand)]
633    pub command: TagsCommand,
634}
635
636#[derive(Debug, Subcommand)]
637pub enum TagsCommand {
638    /// Manage tag providers (native config-resource REST — no
639    /// deployed routes involved): list with tag counts + health,
640    /// create a STANDARD provider, delete (guarded)
641    #[command(subcommand)]
642    Provider(TagsProviderCommand),
643
644    /// Browse tags as a tree (Property children filtered by
645    /// default) — providers appear at the root; needs the deployed
646    /// routes (`ign webdev deploy`) — or run fully OFFLINE against
647    /// an export via --from-export
648    Browse {
649        /// Tag path to browse from (default: the root — providers)
650        path: Option<String>,
651        /// Case-insensitive substring filter on name and full path
652        #[arg(long, value_name = "SUBSTR")]
653        filter: Option<String>,
654        /// Include Property children (filtered out by default)
655        #[arg(long)]
656        include_properties: bool,
657        /// Project holding the deployed routes (default ign-cli)
658        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
659        project: String,
660        /// Browse an OFFLINE tag export instead of the gateway (no
661        /// profile, no credential, no routes): the CLI's own `tags
662        /// export` JSON, a legacy `<provider>.json` whole tree, or a
663        /// git-module tags/ directory — mutually exclusive with the
664        /// positional browse path
665        #[arg(long, value_name = "PATH", conflicts_with_all = ["path"])]
666        from_export: Option<PathBuf>,
667    },
668    /// Read one or more tag values (quality and timestamp included)
669    /// — needs the deployed routes
670    Read {
671        /// Tag paths to read, e.g. `[default]T1` (one or more)
672        #[arg(value_name = "PATH", required = true)]
673        paths: Vec<String>,
674        /// Project holding the deployed routes (default ign-cli)
675        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
676        project: String,
677    },
678    /// Write a value to a tag — the value is parsed as a JSON
679    /// scalar (number/bool/null); anything unparseable is sent as a
680    /// string; arrays/objects refuse — needs the deployed routes
681    Write {
682        /// Tag path to write, e.g. `[default]T1`
683        path: String,
684        /// Value to write: JSON scalar (42, 1.5, true), else the raw
685        /// text is sent as a string
686        #[arg(long, value_name = "V")]
687        value: String,
688        /// Project holding the deployed routes (default ign-cli)
689        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
690        project: String,
691    },
692    /// Tag CONFIGURATION CRUD (the surgical edit loop — JSON in,
693    /// JSON out): get/create/edit/delete tag configs through the
694    /// deployed tagConfig route; stringified values are re-parsed so
695    /// agents see real JSON
696    #[command(subcommand)]
697    Config(TagsConfigCommand),
698    /// UDT types and definitions (recursive: parameters + nested
699    /// children) — needs the deployed routes
700    #[command(subcommand)]
701    Udt(TagsUdtCommand),
702    /// Alarm operations: active status (with filters), journal
703    /// history, acknowledge — needs the deployed routes
704    #[command(subcommand)]
705    Alarms(TagsAlarmsCommand),
706    /// Tag history queries (historian-backed values) — needs the
707    /// deployed routes; structurally safe on any rig (data requires
708    /// a provisioned historian)
709    #[command(subcommand)]
710    History(TagsHistoryCommand),
711    /// Export tag subtrees — json writes the gateway's native JSON
712    /// interchange (the lossless round-trip); xml passes the
713    /// gateway's own XML through byte-for-byte; csv is CLI-GENERATED
714    /// and LOSSY (no alarms, legacy 48-column set, numeric enums —
715    /// the gateway cannot export CSV)
716    Export {
717        /// Tag paths to export, e.g. `[default]P5` (one or more)
718        #[arg(value_name = "PATH", required = true)]
719        paths: Vec<String>,
720        /// Output file (`-` = stdout — the raw payload, no envelope;
721        /// default: `<last-path-segment>.<ext>` in the cwd, ext per
722        /// format)
723        #[arg(short = 'o', long, value_name = "FILE")]
724        output: Option<PathBuf>,
725        /// Payload format: json (the gateway's native interchange —
726        /// default), xml (raw gateway bytes), or csv (CLI-generated
727        /// and LOSSY: no alarms, legacy columns only, numeric enum
728        /// coercion — what was dropped prints on stderr)
729        #[arg(long, value_enum, default_value_t = TransferFormat::Json, value_name = "FORMAT")]
730        format: TransferFormat,
731        /// Project holding the deployed routes (default ign-cli)
732        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
733        project: String,
734    },
735    /// Import a tag export into a target provider — json is the
736    /// native interchange; xml/csv ride the gateway's importTags
737    /// passthrough. A loss scan reports what xml/csv would drop or
738    /// coerce BEFORE the import and refuses exit 2 unless --yes.
739    /// Collision behavior: abort (default) refuses on collisions;
740    /// overwrite replaces them (destructive: requires --yes)
741    Import {
742        /// The export file to import (`-` = stdin)
743        #[arg(long, value_name = "FILE")]
744        file: PathBuf,
745        /// Target tag provider (must exist — `ign tags provider
746        /// create NAME` first)
747        #[arg(long, value_name = "NAME")]
748        provider: String,
749        /// Payload format: json (the gateway's native interchange —
750        /// default), xml (gateway XML), or csv (legacy CSV — alarms
751        /// never arrive; the loss scan reports the gap)
752        #[arg(long, value_enum, default_value_t = TransferFormat::Json, value_name = "FORMAT")]
753        format: TransferFormat,
754        /// Collision policy: abort refuses when tags already exist
755        /// (default); overwrite replaces them (destructive:
756        /// requires --yes). merge is Designer-only (not a value)
757        #[arg(long, value_enum, default_value_t = CollisionPolicy::Abort)]
758        collision_policy: CollisionPolicy,
759        /// Project holding the deployed routes (default ign-cli)
760        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
761        project: String,
762    },
763}
764
765/// The bulk-transfer payload format (11-05) — the clap surface for
766/// core's `ExportFormat`/`ImportFormat` (core keeps plain enums; the
767/// From impls live beside this one, the CollisionPolicy precedent).
768/// json is the default so every pre-11-05 invocation parses and
769/// behaves byte-identically.
770#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
771pub enum TransferFormat {
772    /// The gateway's JSON interchange (default — lossless)
773    #[default]
774    Json,
775    /// Gateway XML — RAW-BYTE passthrough both directions (no
776    /// parse/normalize in the transfer path)
777    Xml,
778    /// Legacy CSV — import: gateway passthrough (alarms never
779    /// arrive); export: CLI-GENERATED and LOSSY (the gateway cannot
780    /// export CSV)
781    Csv,
782}
783
784impl std::fmt::Display for TransferFormat {
785    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
786        let label = match self {
787            Self::Json => "json",
788            Self::Xml => "xml",
789            Self::Csv => "csv",
790        };
791        f.write_str(label)
792    }
793}
794
795/// `tags config …` — the configuration CRUD subfamily (05-05,
796/// TAGS-05): the get→edit-file→write-back surgical loop. Definition
797/// files are the configure shape (README's traps table: tagType
798/// discriminator, nested children, alarms-as-LIST).
799#[derive(Debug, Subcommand)]
800pub enum TagsConfigCommand {
801    /// Get a tag's configuration as (pretty) JSON — stringified
802    /// value/defaultValue sub-dicts re-parsed into real JSON
803    Get {
804        /// Tag path, e.g. `[default]P5/T1`
805        path: String,
806        /// Project holding the deployed routes (default ign-cli)
807        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
808        project: String,
809    },
810    /// Create a tag from a JSON definition file (`-` = stdin) —
811    /// aborts on an existing node (collision policy 'a')
812    Create {
813        /// Tag path to create, e.g. `[default]P5/T1`
814        path: String,
815        /// JSON definition file (`-` = stdin): `{tagType, value,
816        /// alarms, …}` — see the README configure-shape table
817        #[arg(long, value_name = "FILE")]
818        file: PathBuf,
819        /// Project holding the deployed routes (default ign-cli)
820        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
821        project: String,
822    },
823    /// Edit a tag's configuration from a JSON definition file (`-` =
824    /// stdin) — overwrites that single node (collision policy 'o')
825    Edit {
826        /// Tag path to edit, e.g. `[default]P5/T1`
827        path: String,
828        /// JSON definition file (`-` = stdin)
829        #[arg(long, value_name = "FILE")]
830        file: PathBuf,
831        /// Project holding the deployed routes (default ign-cli)
832        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
833        project: String,
834    },
835    /// Delete tag configurations — destructive, refused without
836    /// --yes (the guard fires before ANY resolution: zero network
837    /// work)
838    Delete {
839        /// Tag paths to delete (one or more)
840        #[arg(value_name = "PATH", required = true)]
841        paths: Vec<String>,
842        /// Project holding the deployed routes (default ign-cli)
843        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
844        project: String,
845    },
846}
847
848/// `tags udt …` — the UDT subfamily (05-05, TAGS-06).
849#[derive(Debug, Subcommand)]
850pub enum TagsUdtCommand {
851    /// List the provider's UDT types
852    Types {
853        /// Tag provider whose `_types_` folder to browse (default
854        /// `default`)
855        #[arg(long, default_value = "default", value_name = "NAME")]
856        provider: String,
857        /// Project holding the deployed routes (default ign-cli)
858        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
859        project: String,
860    },
861    /// Get a UDT definition (parameters + nested children, recursive)
862    Def {
863        /// UDT type name, e.g. `Motor`
864        name: String,
865        /// Tag provider (default `default`)
866        #[arg(long, default_value = "default", value_name = "NAME")]
867        provider: String,
868        /// Project holding the deployed routes (default ign-cli)
869        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
870        project: String,
871    },
872}
873
874#[derive(Debug, Subcommand)]
875pub enum TagsProviderCommand {
876    /// List the gateway's tag providers (tag counts + health)
877    List,
878    /// Create a STANDARD tag provider (DB-backed providers are out
879    /// of scope at MVP)
880    Create {
881        /// Provider name to create
882        name: String,
883    },
884    /// Delete a tag provider — destructive, refused without --yes
885    Delete {
886        /// Provider name to delete
887        name: String,
888    },
889}
890
891/// `tags alarms …` — the alarm subfamily (05-06, TAGS-07). History
892/// needs a JOURNAL-PROVISIONED gateway (database connection +
893/// alarm-journal profile + general-alarm-settings — default rigs
894/// refuse exit 6 with the provisioning hint); acknowledge is the
895/// gateway-scope 3-arg form, so `--username` is REQUIRED (no
896/// default-guessing) and ack is deliberately NOT `--yes`-guarded
897/// (acknowledging never un-acknowledges anything — a state-advancing
898/// read-adjacent verb).
899#[derive(Debug, Subcommand)]
900pub enum TagsAlarmsCommand {
901    /// List active alarms — eventId/source/state/priority/name
902    Active {
903        /// Filter by alarm source (e.g. `prov:default`)
904        #[arg(long, value_name = "SOURCE")]
905        source: Option<String>,
906        /// Filter by priority (e.g. `High`)
907        #[arg(long, value_name = "PRIORITY")]
908        priority: Option<String>,
909        /// Filter by state (e.g. `Active, Unacknowledged`)
910        #[arg(long, value_name = "STATE")]
911        state: Option<String>,
912        /// Project holding the deployed routes (default ign-cli)
913        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
914        project: String,
915    },
916    /// Query alarm history — requires a journal-provisioned gateway
917    /// (default rigs refuse with the provisioning hint naming the
918    /// missing chain)
919    History {
920        /// Window start: RFC3339 timestamp or epoch-ms
921        #[arg(long, value_name = "T")]
922        start: String,
923        /// Window end: RFC3339 timestamp or epoch-ms
924        #[arg(long, value_name = "T")]
925        end: String,
926        /// Project holding the deployed routes (default ign-cli)
927        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
928        project: String,
929    },
930    /// Acknowledge alarms (explicit --username: the 3-arg wire form
931    /// needs it). NOT --yes-guarded — acknowledging never
932    /// un-acknowledges anything
933    Ack {
934        /// Alarm event ids to acknowledge (from `alarms active`)
935        #[arg(value_name = "ID", required = true)]
936        ids: Vec<String>,
937        /// Acknowledgement note (default: empty)
938        #[arg(long, value_name = "NOTE")]
939        note: Option<String>,
940        /// The username acknowledging (REQUIRED — the 3-arg wire
941        /// form needs it; the CLI never guesses one)
942        #[arg(long, value_name = "NAME")]
943        username: String,
944        /// Project holding the deployed routes (default ign-cli)
945        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
946        project: String,
947    },
948}
949
950/// `tags history …` — the historian subfamily (05-06, TAGS-08).
951#[derive(Debug, Subcommand)]
952pub enum TagsHistoryCommand {
953    /// Query historical tag values — t_stamp + one column per tag;
954    /// structurally safe anywhere (data requires a provisioned
955    /// historian)
956    Query {
957        /// Tag paths to query, e.g. `[default]T1` (one or more)
958        #[arg(value_name = "PATH", required = true)]
959        paths: Vec<String>,
960        /// Window start: RFC3339 timestamp or epoch-ms
961        #[arg(long, value_name = "T")]
962        start: String,
963        /// Window end: RFC3339 timestamp or epoch-ms
964        #[arg(long, value_name = "T")]
965        end: String,
966        /// Maximum rows returned
967        #[arg(long, value_name = "N")]
968        return_size: Option<i64>,
969        /// Aggregation mode (e.g. `LastValue`, `Average`, `MinMax`);
970        /// default is the route's LastValue
971        #[arg(long, value_name = "MODE")]
972        aggregation: Option<String>,
973        /// Project holding the deployed routes (default ign-cli)
974        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
975        project: String,
976    },
977}
978
979/// Rig args (04-01, RIG-01): `--rig` rides the TOP level (the
980/// SessionsArgs `--type` precedent) so every rig verb shares it. The
981/// command enum carries ONLY the wired verbs — later plans extend it
982/// one variant at a time (the established extend-per-plan chore; no
983/// `unimplemented!()` stubs, every commit compiles clippy-clean).
984#[derive(Debug, clap::Args)]
985pub struct RigArgs {
986    /// Rig to operate on (default: IGNITION_RIG, then [rig].default,
987    /// then the cwd/convention scan)
988    #[arg(long, value_name = "NAME")]
989    pub rig: Option<String>,
990
991    #[command(subcommand)]
992    pub command: RigCommand,
993}
994
995#[derive(Debug, Subcommand)]
996pub enum RigCommand {
997    /// Bring the rig up (compose up -d --wait) and wait for the
998    /// gateway: RUNNING, or uncommissioned-as-data (exit 0 + wizard
999    /// hint in warnings)
1000    Up {
1001        /// Wait budget in seconds — BOTH compose's --wait-timeout and
1002        /// the commissioned probe deadline (default 300)
1003        #[arg(long, default_value_t = 300, value_name = "SECS")]
1004        timeout: u64,
1005    },
1006    /// Stop the rig (compose down --remove-orphans; volumes KEPT —
1007    /// `reset` owns the teardown half)
1008    Down,
1009    /// Tear the rig down AND remove its volumes (down -v
1010    /// --remove-orphans), then bring it back up fresh — destructive,
1011    /// refused without --yes; no stale project/trial state survives
1012    Reset {
1013        /// Wait budget in seconds — BOTH compose's --wait-timeout and
1014        /// the commissioned probe deadline (default 300)
1015        #[arg(long, default_value_t = 300, value_name = "SECS")]
1016        timeout: u64,
1017    },
1018    /// Structured status: services, ports, volumes (allowlist JSON;
1019    /// a down rig is exit-0 data)
1020    Status,
1021    /// Stream the rig's container logs (compose logs passthrough —
1022    /// raw lines, no envelope in any mode; the third streaming
1023    /// exception, README-documented)
1024    Logs {
1025        /// Lines to show from the end of each service's logs
1026        #[arg(long, default_value_t = 200, value_name = "N")]
1027        tail: u32,
1028        /// Follow: stream new lines as they occur (Ctrl-C stops —
1029        /// default process kill, no envelope)
1030        #[arg(short = 'f', long)]
1031        follow: bool,
1032        /// One service's logs only (see `ign rig status` for names)
1033        service: Option<String>,
1034    },
1035    /// Trial-license state: status is credential-free truth; reset
1036    /// (guarded) restarts an EXPIRED trial via the mechanism ladder
1037    /// (token-auth POST, else native gateway login)
1038    Trial(TrialArgs),
1039    /// Snapshot the rig's gateway: native gwbk (roaming backup,
1040    /// streamed) + per-project exports + manifest.json, composed in a
1041    /// timestamped directory — repeatable state
1042    Snapshot {
1043        /// Output directory (default:
1044        /// ./ign-rig-snapshots/<rig>-<yyyyMMdd-HHmmss>/)
1045        #[arg(short = 'o', long, value_name = "DIR")]
1046        output: Option<PathBuf>,
1047    },
1048    /// Restore a gwbk onto the rig's gateway — destructive, refused
1049    /// without --yes; synchronous restore + restart, then a witnessed
1050    /// RUNNING wait
1051    Restore {
1052        /// The gwbk file to restore (from `ign rig snapshot`)
1053        #[arg(long, value_name = "PATH")]
1054        file: PathBuf,
1055        /// Post-restore RUNNING wait budget in seconds (floored at
1056        /// 300 — the gateway restarts after a restore)
1057        #[arg(long, default_value_t = 300, value_name = "SECS")]
1058        timeout: u64,
1059    },
1060}
1061
1062/// Trial subcommands (04-03, RIG-02/03). `status` reads the
1063/// unauthenticated trial endpoint + banners cross-check. `reset` is
1064/// the family's destructive verb (`--yes`-guarded, exit 2 without —
1065/// the reset precedent); the password NEVER rides a flag (env/secret
1066/// only — redaction discipline).
1067#[derive(Debug, clap::Args)]
1068pub struct TrialArgs {
1069    #[command(subcommand)]
1070    pub command: TrialCommand,
1071}
1072
1073#[derive(Debug, Clone, Subcommand)]
1074pub enum TrialCommand {
1075    /// Show the trial state: licenseMode, trialState, seconds left,
1076    /// expired — plus the banners cross-check. No credential needed
1077    /// (the endpoints answer unauthenticated — fresh-rig friendly)
1078    Status,
1079    /// Reset an EXPIRED trial to a fresh window — destructive,
1080    /// refused without --yes. Mechanism ladder: API-token POST
1081    /// (IGNITION_TOKEN) → native gateway login (--user /
1082    /// IGNITION_USER + IGNITION_PASSWORD). Non-expired trials refuse
1083    /// (trial_not_expired)
1084    Reset {
1085        /// Gateway admin username for the login rung (password comes
1086        /// from IGNITION_PASSWORD — never a flag)
1087        #[arg(long, value_name = "NAME")]
1088        user: Option<String>,
1089    },
1090}
1091
1092/// Backup subcommands (07-02, BKUP-01) — the standalone surface of
1093/// the Phase 4 gwbk wire. `restore` is the family's destructive verb
1094/// (the 8th `--yes`-guarded set member: it REPLACES this gateway's
1095/// state); `download` is a streamed read, unguarded.
1096#[derive(Debug, clap::Args)]
1097pub struct BackupArgs {
1098    #[command(subcommand)]
1099    pub command: BackupCommand,
1100}
1101
1102#[derive(Debug, Subcommand)]
1103pub enum BackupCommand {
1104    /// Download a gwbk backup (streamed to disk)
1105    Download {
1106        /// Output file (default: the gateway's Content-Disposition
1107        /// name, else <profile>-backup.gwbk)
1108        #[arg(short, long, value_name = "FILE")]
1109        output: Option<PathBuf>,
1110        /// Backup type: roaming = portable across gateways (default);
1111        /// all includes gateway-specific state
1112        #[arg(long, value_enum, default_value_t = CliBackupType::Roaming)]
1113        r#type: CliBackupType,
1114    },
1115    /// Restore a gwbk onto THIS gateway — destructive, refused
1116    /// without --yes; the gateway restarts and blocks for minutes
1117    /// after the restore
1118    Restore {
1119        /// The gwbk file to restore (from `ign backup download` or
1120        /// `ign rig snapshot`)
1121        file: PathBuf,
1122    },
1123}
1124
1125/// CLI value-enum mirror of the core `BackupType` (ignition-core
1126/// stays clap-free; `From` converts at the dispatch seam).
1127#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
1128pub enum CliBackupType {
1129    /// Portable backup (cross-gateway)
1130    Roaming,
1131    /// Includes gateway-specific state
1132    All,
1133}
1134
1135impl From<CliBackupType> for ignition_core::client::backup::BackupType {
1136    fn from(value: CliBackupType) -> Self {
1137        match value {
1138            CliBackupType::Roaming => Self::Roaming,
1139            CliBackupType::All => Self::All,
1140        }
1141    }
1142}
1143
1144/// EAM subcommands (07-02, BKUP-02) — the read-heavy surface with
1145/// guarded writes. Every runtime verb honestly reports the
1146/// controller-mode state gate (`eam_not_controller` on a stock
1147/// gateway — the README documents the manual flip).
1148#[derive(Debug, clap::Args)]
1149pub struct EamArgs {
1150    #[command(subcommand)]
1151    pub command: EamCommand,
1152}
1153
1154#[derive(Debug, Subcommand)]
1155pub enum EamCommand {
1156    /// EAM task run history (the gateway's own newest-first order)
1157    History {
1158        /// Max entries (default 200 — ALWAYS sent explicitly; the
1159        /// server default is unlimited)
1160        #[arg(long, value_name = "N")]
1161        limit: Option<u32>,
1162        /// Substring search over task names
1163        #[arg(long, value_name = "TEXT")]
1164        search: Option<String>,
1165    },
1166    /// Task definitions: bare `ign eam tasks` lists; with a name
1167    /// shows one definition + its scheduled state
1168    Tasks {
1169        /// One task definition's detail (omit to list all)
1170        name: Option<String>,
1171    },
1172    /// Task definition writes (create; force-dispatch) — `eam task`
1173    /// requires a subcommand (the `rig trial` shape)
1174    #[command(subcommand)]
1175    Task(EamTaskCommand),
1176}
1177
1178/// `eam task …` — the guarded writes (07-02 Task 3; the five
1179/// lifecycle/mutation verbs arrive 10-04). `new` carries the typed
1180/// guard ladder (backup+OnDemand unguarded; mutating types and any
1181/// non-OnDemand schedule need --yes; restore/install/upgrade types
1182/// refuse outright — see README); `force` dispatches NOW, always
1183/// guarded; `suspend`/`resume`/`cancel`/`modify`/`delete` are the
1184/// two-tier guarded verbs (pure precheck → blast-radius preview
1185/// fetch → confirm-with-preview → action — the refusal message IS
1186/// the blast radius).
1187#[derive(Debug, Subcommand)]
1188pub enum EamTaskCommand {
1189    /// Create a task definition (scheduleMode defaults to OnDemand —
1190    /// never auto-fires)
1191    New {
1192        /// Task definition name (any resource name — e.g. nightly-backup)
1193        name: String,
1194        /// Task type — the openapi taxonomy, three classes with different guard consequences:
1195        ///
1196        /// - benign (no --yes with the default OnDemand schedule): eam_backup
1197        ///
1198        /// - mutating (need --yes — they act on their agent targets when dispatched):
1199        ///   eam_restart, eam_sendProject, eam_sendResource, eam_sendTags,
1200        ///   eam_activateLicense, eam_updateLicense, eam_unactivateLicense
1201        ///
1202        /// - refused (exit 6 eam_task_type_refused — fleet-destructive; run from the EAM console):
1203        ///   eam_restoreBackup, eam_installModules, eam_remoteUpgrade
1204        ///
1205        /// Any OTHER type fails safe to the --yes rung (unknown future
1206        /// types stay accepted, never silently unguarded).
1207        ///
1208        /// Example: ign eam task new nightly-backup eam_backup --target gw-a
1209        r#type: String,
1210        /// Target gateway name (repeatable; the GNET agent id)
1211        #[arg(long, value_name = "NAME")]
1212        target: Vec<String>,
1213        /// Setting as K=V with scalar auto-typing — bool/int ride
1214        /// typed, anything else stays a string (repeatable;
1215        /// arrays/objects need --definition)
1216        #[arg(long, value_name = "K=V")]
1217        setting: Vec<String>,
1218        /// Full-JSON settings file deep-merged over the composed
1219        /// `config.settings` (the typed/array settings path)
1220        #[arg(long, value_name = "PATH", conflicts_with = "setting")]
1221        definition: Option<PathBuf>,
1222        /// Schedule mode (default OnDemand — never auto-fires;
1223        /// Immediate/Scheduled/AtTime/AtDelay require --yes)
1224        #[arg(long, value_enum, default_value_t = ScheduleMode::OnDemand)]
1225        schedule_mode: ScheduleMode,
1226    },
1227    /// Force-dispatch a task NOW — destructive, refused without
1228    /// --yes (it dispatches to the agent targets immediately). The
1229    /// confirmation prompt (and every refusal) carries the
1230    /// blast-radius preview line.
1231    Force {
1232        /// Task definition name to dispatch
1233        name: String,
1234    },
1235    /// Suspend a task definition's scheduled dispatches — TASK-scoped
1236    /// runtime verb (there is NO agent-level suspend on the wire; an
1237    /// "agent" is suspended by suspending its tasks). Refused exit 2
1238    /// without --yes, the blast-radius preview in the refusal
1239    /// message; on a stock gateway the runtime seam honestly refuses
1240    /// `eam_not_controller` (exit 6).
1241    Suspend {
1242        /// Task definition name to suspend
1243        name: String,
1244    },
1245    /// Resume a suspended task definition — the suspend inverse
1246    /// (TASK-scoped, like suspend). Refused exit 2 without --yes
1247    /// with the blast-radius preview in the message.
1248    Resume {
1249        /// Task definition name to resume
1250        name: String,
1251    },
1252    /// Cancel a task's PENDING execution (TASK-scoped). Refused exit
1253    /// 2 without --yes with the blast-radius preview in the message;
1254    /// nothing pending is an honest no-op (`fired: false`), a
1255    /// `canCancel: false` row reports the gateway's own refusal.
1256    Cancel {
1257        /// Task definition whose pending execution to cancel
1258        name: String,
1259    },
1260    /// Rewrite targeted keys of a task definition — the full-record
1261    /// read-modify-write (every key the gateway answered rides back;
1262    /// only the targeted keys change). Rename is deliberately
1263    /// ABSENT (the wire answers PUT-rename with 404 — see README's
1264    /// create-new + delete-old composite). Refused exit 2 without
1265    /// --yes with the blast-radius preview in the message.
1266    ///
1267    /// Example: ign eam task modify nightly-backup --enable
1268    Modify {
1269        /// Task definition to modify
1270        name: String,
1271        /// Flip the definition's `enabled` key to true
1272        #[arg(long)]
1273        enable: bool,
1274        /// Flip the definition's `enabled` key to false
1275        /// (conflicts with --enable)
1276        #[arg(long, conflicts_with = "enable")]
1277        disable: bool,
1278        /// Rewrite `config.profile.scheduleMode` (e.g. OnDemand,
1279        /// Scheduled — the scheduleDetails string itself is out of
1280        /// scope; the read-back shows the landed pairing)
1281        #[arg(long, value_name = "MODE")]
1282        schedule_mode: Option<String>,
1283        /// Deep-merge a settings entry over the found
1284        /// `config.settings` (K=V with scalar auto-typing;
1285        /// repeatable — objects merge recursively, arrays/scalars
1286        /// replace)
1287        #[arg(long, value_name = "K=V")]
1288        setting: Vec<String>,
1289        /// Rewrite the definition's `description`
1290        #[arg(long, value_name = "TEXT")]
1291        description: Option<String>,
1292    },
1293    /// Delete a task definition — signature-keyed (the gateway
1294    /// refuses a stale signature), behind the same --yes guard.
1295    /// Refused exit 2 without --yes with the blast-radius preview
1296    /// in the message.
1297    Delete {
1298        /// Task definition to delete
1299        name: String,
1300    },
1301}
1302
1303/// Schedule modes (the openapi taxonomy's user-facing subset —
1304/// SuspendedByFailover is system-owned, not a CLI value).
1305#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
1306pub enum ScheduleMode {
1307    /// Never fires on its own (force dispatches it)
1308    OnDemand,
1309    /// Fires immediately on create (requires --yes)
1310    Immediate,
1311    /// Fires on a schedule (requires --yes)
1312    Scheduled,
1313    /// Fires at an absolute time (requires --yes)
1314    AtTime,
1315    /// Fires after a delay (requires --yes)
1316    AtDelay,
1317}
1318
1319impl ScheduleMode {
1320    /// The wire token.
1321    pub fn wire(self) -> &'static str {
1322        match self {
1323            Self::OnDemand => "OnDemand",
1324            Self::Immediate => "Immediate",
1325            Self::Scheduled => "Scheduled",
1326            Self::AtTime => "AtTime",
1327            Self::AtDelay => "AtDelay",
1328        }
1329    }
1330}
1331
1332/// Script subcommands (07-03, SCRPT-01) — the smallest family: ONE
1333/// verb over the already-shipped, already-secured scriptExec route.
1334/// `script` requires a subcommand (the `rig trial` shape — no bare
1335/// row); the opt-in is STRUCTURAL (the route deploys only via
1336/// `ign webdev deploy --with-script-exec`, which persists the
1337/// secret at 0600), so `run` carries NO `--yes` guard by design —
1338/// the deploy flag IS the opt-in and agents need the verb
1339/// non-interactive (the research-adopted decision).
1340#[derive(Debug, clap::Args)]
1341pub struct ScriptArgs {
1342    #[command(subcommand)]
1343    pub command: ScriptCommand,
1344}
1345
1346/// `ign lint` args (07-04, INTR-02) — the ignition-lint delegation:
1347/// PATHS map to `--target <path>` pairs on the child's arg vector;
1348/// `--` passthrough args ride verbatim after them (power users:
1349/// `--profile`, `--checks`, `--fail-on`, …).
1350#[derive(Debug, clap::Args)]
1351pub struct LintArgs {
1352    /// Paths to lint (one --target pair each)
1353    #[arg(value_name = "PATH", required = true)]
1354    pub paths: Vec<String>,
1355    /// Exit with the linter's own exit code (CI mode). Default is
1356    /// the doctor posture: findings + child_exit_code + the parsed
1357    /// report ride as data and the command exits 0 whenever the
1358    /// tool RAN
1359    #[arg(long)]
1360    pub strict: bool,
1361    /// Extra args passed to ignition-lint verbatim (after --)
1362    #[arg(last = true, value_name = "ARGS")]
1363    pub passthrough: Vec<String>,
1364}
1365
1366#[derive(Debug, Subcommand)]
1367pub enum ScriptCommand {
1368    /// Execute gateway-side Python (Jython) — non-interactive, the
1369    /// route's entire purpose
1370    Run {
1371        /// Inline Python source (a one-liner's best form)
1372        #[arg(long, value_name = "PY")]
1373        code: Option<String>,
1374        /// Python source file (`-` reads stdin — the agent pipe
1375        /// path); giving both --code and --file refuses
1376        /// `invalid_input` (exit 2) pre-resolution
1377        #[arg(long, value_name = "PATH")]
1378        file: Option<String>,
1379        /// The deployed routes' project (default ign-cli — where
1380        /// `ign webdev deploy` put scriptExec)
1381        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1382        project: String,
1383    },
1384}
1385
1386/// `ign api` args (09-03, EXT-01) — the raw-passthrough family. One
1387/// verb today (`call`); the escape hatch for the 80+ gateway endpoint
1388/// families no curated command covers.
1389#[derive(Debug, clap::Args)]
1390pub struct ApiArgs {
1391    #[command(subcommand)]
1392    pub command: ApiCommand,
1393}
1394
1395#[derive(Debug, Subcommand)]
1396pub enum ApiCommand {
1397    /// Call any gateway REST endpoint raw: `--method`/`--path` are the
1398    /// request, `--data`/`--header`/`--query` shape it, and the
1399    /// envelope's `data.result.data` is the gateway's JSON VERBATIM
1400    /// (no field dropped, no value coerced, key order preserved — the
1401    /// README's documented contract exception). Auth-pattern headers
1402    /// (`Authorization`, `X-Ignition-API-Token`, `Cookie`) are
1403    /// refused pre-I/O — credentials come from the profile.
1404    Call(ApiCallArgs),
1405}
1406
1407/// `ign api call` args (09-03, EXT-01). Arbitrary verbs accepted —
1408/// reqwest's `Method` parser validates (usage-class refusal for
1409/// garbage); `--data` is RAW TEXT on ANY method (GET/DELETE bodies
1410/// allowed — curl parity; the gateway's answer classifies); ONE query
1411/// mechanism (repeatable `--query k=v`, never `?` in `--path`).
1412#[derive(Debug, clap::Args)]
1413pub struct ApiCallArgs {
1414    /// HTTP method (GET, POST, PUT, DELETE, PATCH, HEAD, …)
1415    #[arg(long, value_name = "METHOD")]
1416    pub method: String,
1417    /// Absolute path on the gateway (must start with `/`; no `?`, no host)
1418    #[arg(long, value_name = "PATH")]
1419    pub path: String,
1420    /// Raw body text passthrough (rides ANY method — curl parity)
1421    #[arg(long, value_name = "TEXT")]
1422    pub data: Option<String>,
1423    /// Extra request header `Name: Value` (repeatable; split on the
1424    /// FIRST `:`; auth-pattern names are refused pre-I/O)
1425    #[arg(long, value_name = "NAME: VALUE")]
1426    pub header: Vec<String>,
1427    /// Query pair `k=v` (repeatable; split on the FIRST `=` — the
1428    /// ONE query mechanism)
1429    #[arg(long, value_name = "K=V")]
1430    pub query: Vec<String>,
1431}
1432
1433/// `ign license` args (09-04, EXT-02) — the license morning-check
1434/// family. One verb today; room to grow (the uniform-leaf shape).
1435#[derive(Debug, clap::Args)]
1436pub struct LicenseArgs {
1437    #[command(subcommand)]
1438    pub command: LicenseCommand,
1439}
1440
1441#[derive(Debug, Subcommand)]
1442pub enum LicenseCommand {
1443    /// License inventory + trial state in ONE command: the
1444    /// hardware-key item rows and effective stamp ride the
1445    /// `/licenses` read, the license mode + countdown ride the trial
1446    /// companion (the mode is NOT on the licenses payload — capture
1447    /// fact)
1448    Status,
1449}
1450
1451/// `ign redundancy` args (09-04, EXT-02) — the redundancy
1452/// morning-check family.
1453#[derive(Debug, clap::Args)]
1454pub struct RedundancyArgs {
1455    #[command(subcommand)]
1456    pub command: RedundancyCommand,
1457}
1458
1459#[derive(Debug, Subcommand)]
1460pub enum RedundancyCommand {
1461    /// The flat redundancy status: role, project state, peer
1462    /// connection, config access, sync/failover pending, uptime
1463    /// (ms since gateway start — capture-proven) and last-sync (the
1464    /// `-1` never-synced sentinel on fresh rigs)
1465    Status,
1466}
1467
1468/// `ign gan` args (09-04, EXT-02) — the Gateway Area Network
1469/// morning-check family.
1470#[derive(Debug, clap::Args)]
1471pub struct GanArgs {
1472    #[command(subcommand)]
1473    pub command: GanCommand,
1474}
1475
1476#[derive(Debug, Subcommand)]
1477pub enum GanCommand {
1478    /// The GAN overview: total/running connections, in/out byte
1479    /// rates, remote gateways — all zeros are healthy data on a
1480    /// non-GAN gateway (the capture IS the canonical shape)
1481    Status,
1482}
1483
1484/// `ign diagnostics` args (09-05, EXT-02) — the support-bundle
1485/// family: `bundle` nests one level (the `tags provider` grouped-
1486/// subfamily pattern).
1487#[derive(Debug, clap::Args)]
1488pub struct DiagnosticsArgs {
1489    #[command(subcommand)]
1490    pub command: DiagnosticsCommand,
1491}
1492
1493#[derive(Debug, Subcommand)]
1494pub enum DiagnosticsCommand {
1495    /// Support-bundle operations (generate / status / download /
1496    /// wait) — the state machine is ENCODED FROM LIVE CAPTURES:
1497    /// observed states are exactly `Generating` (mid-generation) and
1498    /// `Valid` (terminal/ready), PascalCase, both rigs
1499    #[command(subcommand)]
1500    Bundle(BundleCommand),
1501}
1502
1503/// The four bundle verbs (09-05). Nothing here is destructive:
1504/// generate creates a support bundle, download writes a local file,
1505/// wait polls — none carry `--yes` (the backup-download posture).
1506#[derive(Debug, Subcommand)]
1507pub enum BundleCommand {
1508    /// Start bundle generation (POST, no body) — the 200 answer IS
1509    /// the fresh status (live capture: `{"state":"Generating"}`);
1510    /// generation takes ~2–6 s on a fresh rig, so pair with `wait`
1511    Generate,
1512    /// The bundle status: `state` rides the CAPTURED vocabulary
1513    /// (`Generating` / `Valid` — unknown future states pass through
1514    /// verbatim, never refused); `fileSize` (bytes) appears only
1515    /// when `Valid`
1516    Status,
1517    /// Download the bundle ZIP (streamed to disk; the request rides
1518    /// a 300 s per-request timeout — the 30 s client default would
1519    /// truncate MB-sized bundles)
1520    Download {
1521        /// Output file (default: the gateway's Content-Disposition
1522        /// name, else ignition-diagnostics-bundle-<unix_ts>.zip)
1523        #[arg(short, long, value_name = "FILE")]
1524        output: Option<PathBuf>,
1525    },
1526    /// Poll the status until a terminal captured state (`Valid`)
1527    /// — an UNKNOWN state (outside the captured vocabulary) keeps
1528    /// polling honestly until the deadline; deadline expiry is
1529    /// exit 4 `network_error` (the restart-wait convention, no new
1530    /// slug) with the last observed state in the message
1531    Wait {
1532        /// Poll interval in seconds
1533        #[arg(long, default_value_t = 2, value_name = "SECS")]
1534        interval: u64,
1535        /// Give up after this many seconds (default 300 — mirrors
1536        /// restart --wait)
1537        #[arg(long, default_value_t = 300, value_name = "SECS")]
1538        timeout: u64,
1539    },
1540}
1541
1542/// Profile subcommands (nested: a struct wrapper carrying the subcommand
1543/// enum, so `Commands::Profile` gets an `Args` payload).
1544#[derive(Debug, clap::Args)]
1545pub struct ProfileArgs {
1546    #[command(subcommand)]
1547    pub command: ProfileCmd,
1548}
1549
1550/// Sessions args: the family filter rides the TOP level so bare
1551/// `ign sessions [--type X]` lists (must-have truth #1) while
1552/// `ign sessions terminate …` carries the destructive half.
1553#[derive(Debug, clap::Args)]
1554pub struct SessionsArgs {
1555    /// Filter to one session family (default: all three merged)
1556    #[arg(long, value_enum)]
1557    pub r#type: Option<SessionType>,
1558
1559    #[command(subcommand)]
1560    pub command: Option<SessionsCmd>,
1561}
1562
1563#[derive(Debug, Subcommand)]
1564pub enum SessionsCmd {
1565    /// Terminate (designer: prune / vision: close) a session —
1566    /// destructive, refused without --yes
1567    Terminate {
1568        /// Session family holding the id
1569        #[arg(long, value_enum)]
1570        r#type: SessionType,
1571        /// Session/client id to terminate (see `ign sessions`)
1572        #[arg(long)]
1573        id: String,
1574        /// Message shown to the session's user (Perspective only)
1575        #[arg(long)]
1576        message: Option<String>,
1577    },
1578}
1579
1580/// Logs args: the query filters ride the TOP level so bare
1581/// `ign logs [-f]` lists/tails (must-have truth #1) while `download`
1582/// and the `loggers` subtree hang off the optional subcommand — the
1583/// SessionsArgs precedent.
1584#[derive(Debug, clap::Args)]
1585pub struct LogsArgs {
1586    /// Only entries from this logger (name prefix)
1587    #[arg(long, value_name = "NAME")]
1588    pub logger: Option<String>,
1589    /// Minimum level to include (server-side filter)
1590    #[arg(long, value_enum, value_name = "LEVEL")]
1591    pub min_level: Option<LogLevel>,
1592    /// Start from an absolute EPOCH-MS or a relative span (500ms, 30s,
1593    /// 5min, 2h) — parsed to epoch-ms at arg-parse time
1594    #[arg(
1595        long,
1596        value_name = "EPOCH_MS|Nms|Ns|Nmin|Nh",
1597        value_parser = parse_since_arg
1598    )]
1599    pub since: Option<i64>,
1600    /// Max entries — the server default is UNLIMITED, never used here
1601    #[arg(long, default_value_t = 200)]
1602    pub limit: i64,
1603    /// Follow: stream new entries as they occur (poll-based)
1604    #[arg(short = 'f', long)]
1605    pub follow: bool,
1606    /// Poll interval in seconds (follow mode)
1607    #[arg(long, default_value_t = 2, value_name = "SECS")]
1608    pub interval: u64,
1609    /// Stop after this many seconds (follow mode; default: until Ctrl-C)
1610    #[arg(long, value_name = "SECS")]
1611    pub timeout: Option<u64>,
1612    #[command(subcommand)]
1613    pub command: Option<LogsCmd>,
1614}
1615
1616#[derive(Debug, Subcommand)]
1617pub enum LogsCmd {
1618    /// Download the log archive — a SQLite .idb, never a zip
1619    Download {
1620        /// Output file (default: the gateway's Content-Disposition
1621        /// name, else <profile>-logs-<ts>.idb)
1622        #[arg(short, long, value_name = "FILE")]
1623        output: Option<PathBuf>,
1624    },
1625    /// List loggers / manage logger levels
1626    Loggers(LoggersArgs),
1627}
1628
1629#[derive(Debug, clap::Args)]
1630pub struct LoggersArgs {
1631    /// Substring search over logger names
1632    #[arg(long, value_name = "TEXT")]
1633    pub search: Option<String>,
1634    #[command(subcommand)]
1635    pub command: Option<LoggersCmd>,
1636}
1637
1638#[derive(Debug, Subcommand)]
1639pub enum LoggersCmd {
1640    /// Set one logger's level — a mutation, refused without --yes
1641    Set {
1642        /// Logger name (see `ign logs loggers`)
1643        name: String,
1644        /// Level to set
1645        #[arg(value_enum)]
1646        level: LogLevel,
1647    },
1648    /// Reset ALL logger levels to defaults — refused without --yes
1649    Reset,
1650}
1651
1652/// The seven spec-documented log levels (TRACE..OFF), value-enum form
1653/// for clap; [`LogLevel::wire`] yields the uppercase wire token.
1654#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
1655pub enum LogLevel {
1656    Trace,
1657    Debug,
1658    Info,
1659    Warn,
1660    Error,
1661    Fatal,
1662    Off,
1663}
1664
1665impl LogLevel {
1666    /// The uppercase wire token the gateway expects.
1667    pub fn wire(self) -> &'static str {
1668        match self {
1669            Self::Trace => "TRACE",
1670            Self::Debug => "DEBUG",
1671            Self::Info => "INFO",
1672            Self::Warn => "WARN",
1673            Self::Error => "ERROR",
1674            Self::Fatal => "FATAL",
1675            Self::Off => "OFF",
1676        }
1677    }
1678}
1679
1680/// clap value parser delegating to the core `--since` grammar against
1681/// the current time (a relative span resolves at parse time) — invalid
1682/// specs are clap usage errors (exit 2) like any bad flag value.
1683fn parse_since_arg(spec: &str) -> Result<i64, String> {
1684    let now_ms = std::time::SystemTime::now()
1685        .duration_since(std::time::UNIX_EPOCH)
1686        .expect("system clock is after the unix epoch")
1687        .as_millis() as i64;
1688    ignition_core::actions::logs::parse_since(spec, now_ms)
1689}
1690
1691/// CLI value-enum mirrors of the core action enums (ignition-core stays
1692/// clap-free; `From` converts at the dispatch seam).
1693#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
1694pub enum SessionType {
1695    /// Designer sessions
1696    Designer,
1697    /// Perspective browser sessions
1698    Perspective,
1699    /// Vision clients
1700    Vision,
1701}
1702
1703impl From<SessionType> for ignition_core::actions::sessions::SessionType {
1704    fn from(value: SessionType) -> Self {
1705        match value {
1706            SessionType::Designer => Self::Designer,
1707            SessionType::Perspective => Self::Perspective,
1708            SessionType::Vision => Self::Vision,
1709        }
1710    }
1711}
1712
1713#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
1714pub enum ConnectionType {
1715    /// Database connections
1716    Database,
1717    /// OPC connections
1718    Opc,
1719}
1720
1721impl From<ConnectionType> for ignition_core::actions::connections::ConnectionType {
1722    fn from(value: ConnectionType) -> Self {
1723        match value {
1724            ConnectionType::Database => Self::Database,
1725            ConnectionType::Opc => Self::Opc,
1726        }
1727    }
1728}
1729
1730/// Import collision policy (03-02). Exactly the two values REST
1731/// exposes — `merge` is the Designer import popup's vocabulary and is
1732/// NOT a value: clap's invalid-value error lists the two real
1733/// choices, and the README documents merge as Designer-only (the
1734/// sanctioned rejection-with-hint shape).
1735#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
1736pub enum CollisionPolicy {
1737    /// Refuse when the project already exists (default)
1738    Abort,
1739    /// Replace the ENTIRE project (resources absent from the ZIP are
1740    /// deleted) — destructive, requires --yes
1741    Overwrite,
1742}
1743
1744impl From<CollisionPolicy> for ignition_core::actions::projects::CollisionPolicy {
1745    fn from(value: CollisionPolicy) -> Self {
1746        match value {
1747            CollisionPolicy::Abort => Self::Abort,
1748            CollisionPolicy::Overwrite => Self::Overwrite,
1749        }
1750    }
1751}
1752
1753#[derive(Debug, Subcommand)]
1754#[command(arg_required_else_help = true)]
1755pub enum ProfileCmd {
1756    /// Add (or overwrite) a gateway profile
1757    Add {
1758        /// Profile name
1759        name: String,
1760        /// Gateway base URL (e.g. http://localhost:9088)
1761        url: String,
1762        /// Optional display label
1763        #[arg(long, value_name = "TEXT")]
1764        label: Option<String>,
1765        /// Name of the env var holding the auth token
1766        #[arg(long, value_name = "VAR")]
1767        token_env: Option<String>,
1768        /// Keyring user string for the token (service is always ignition-cli)
1769        #[arg(long, value_name = "USER")]
1770        keyring: Option<String>,
1771        /// Name of the env var holding the basic-auth user (with --password-env)
1772        #[arg(long, value_name = "VAR")]
1773        user_env: Option<String>,
1774        /// Name of the env var holding the basic-auth password (with --user-env)
1775        #[arg(long, value_name = "VAR")]
1776        password_env: Option<String>,
1777        /// Make this profile the active one
1778        #[arg(long)]
1779        active: bool,
1780    },
1781    /// List configured profiles
1782    List,
1783    /// Switch the active profile
1784    Use {
1785        /// Profile name to activate
1786        name: String,
1787    },
1788}