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    /// Adopt this profile's gateway: native login → mint an
124    /// Administrator-level API key (idempotent by name) → wire the
125    /// gateway's read/write permissions → live-probe the key →
126    /// persist the credential (OS keyring, env-var fallback). Re-run
127    /// on an adopted gateway is an all-skip no-op. Composition flags
128    /// ride the bootstrap: --project deploys the CLI's WebDev routes,
129    /// --checkout lands every enabled project locally, --bake saves a
130    /// restore-ready gwbk
131    Adopt {
132        /// Gateway login user for the native OIDC dance
133        /// (default: admin)
134        #[arg(long, value_name = "NAME")]
135        user: Option<String>,
136
137        /// The API-key name — the idempotency key (default: ign-cli)
138        #[arg(long, value_name = "NAME")]
139        key_name: Option<String>,
140
141        /// The granted security level as a slash path
142        /// (default: Authenticated/Roles/Administrator)
143        #[arg(long, value_name = "PATH")]
144        level: Option<String>,
145
146        /// Deploy the CLI's WebDev routes (scriptExec on) into this
147        /// project after the bootstrap
148        #[arg(long, value_name = "NAME")]
149        project: Option<String>,
150
151        /// Ship the embedded TESTING bundle with the routes (the
152        /// Jython framework + testing/run|tags routes + the smoke
153        /// sentinel; the step asserts discover ≥1 module and a green
154        /// smoke run — the empty-suite trap)
155        #[arg(long, requires = "project")]
156        testing: bool,
157
158        /// Check out every enabled project into DIR/<project>
159        /// (scripts decoded — grep/lint ready); existing targets skip
160        #[arg(long, value_name = "DIR")]
161        checkout: Option<String>,
162
163        /// Download a roaming gwbk to FILE after everything landed —
164        /// a gateway reset restored from it keeps the key and routes
165        #[arg(long, value_name = "FILE")]
166        bake: Option<String>,
167    },
168
169    /// Obtain a live gateway login session (the native OIDC dance) —
170    /// returns the session cookie name/value, the CSRF token, the
171    /// gateway URL, and a ready-to-use Playwright `storageState`
172    /// document. The session material is emitted ONLY under the
173    /// global --json flag; the human render prints the cookie name
174    /// and the gateway URL and withholds the rest
175    #[command(arg_required_else_help = true)]
176    Session(SessionArgs),
177
178    /// Manage gateway projects: list with inheritance info, new, copy,
179    /// rename, set (reparent), delete, export/import (ZIP)
180    #[command(arg_required_else_help = true)]
181    Project(ProjectArgs),
182
183    /// Check out a project's resources to a local tree, inspect drift,
184    /// push guarded changes — the local edit loop over the
185    /// project-export interchange (project RESOURCES only; tag values
186    /// never appear in the tree — tags remain `ign tags` verbs)
187    #[command(arg_required_else_help = true)]
188    Workspace(WorkspaceArgs),
189
190    /// Declared gateway deploy: `render` turns a repo's
191    /// `gateways/<name>/` tree into the bytes the gateway holds on
192    /// disk for one environment — offline (no profile, no network, no
193    /// credentials), deterministic, with a `render_sha256` digest
194    #[command(arg_required_else_help = true)]
195    Deploy(DeployArgs),
196
197    /// Fetch a gateway resource, edit it in $EDITOR, push back
198    /// (guarded) — the kubectl-edit loop over ONE resource:
199    /// unchanged saves are clean no-ops (nothing pushed, never
200    /// prompted), a gateway changed since fetch refuses (never
201    /// force-pushed), a JSON-breaking save refuses fail-closed with
202    /// the temp tree kept (path printed). Zero stdout in every mode —
203    /// the editor owns the terminal (see README's edit section)
204    Edit(EditArgs),
205
206    /// Manage a project's individual resources: list, get, put,
207    /// delete — the surgical edit loop (change one view/script
208    /// without re-importing everything)
209    #[command(arg_required_else_help = true)]
210    Resource(ResourceArgs),
211
212    /// Manage the CLI's own WebDev routes on the gateway: deploy the
213    /// embedded bundle, verify the version handshake
214    #[command(arg_required_else_help = true)]
215    Webdev(WebdevArgs),
216
217    /// Tag operations: manage providers (native REST) and browse /
218    /// read / write tag values (the deployed WebDev routes — run
219    /// `ign webdev deploy` first)
220    #[command(arg_required_else_help = true)]
221    Tags(TagsArgs),
222
223    /// Manage a Docker compose rig: up/down/reset/status/logs/trial
224    /// (snapshot arrives in a later plan) — docker-only, no profile
225    /// needed
226    #[command(arg_required_else_help = true)]
227    Rig(RigArgs),
228
229    /// Manage gateway backups (gwbk) on any profiled gateway:
230    /// download (streamed) and restore (guarded)
231    #[command(arg_required_else_help = true)]
232    Backup(BackupArgs),
233
234    /// EAM task orchestration (Enterprise Administration Module):
235    /// history/definitions reads, guarded task create + force —
236    /// every verb honestly reports the controller-mode state gate
237    #[command(arg_required_else_help = true)]
238    Eam(EamArgs),
239
240    /// Run gateway-side Python (Jython) through the secret-gated
241    /// scriptExec route — the opt-in is STRUCTURAL (`ign webdev
242    /// deploy --with-script-exec` deploys the route + persists its
243    /// secret); there is no --yes on this verb by design
244    #[command(arg_required_else_help = true)]
245    Script(ScriptArgs),
246
247    /// Run the gateway's own Jython test suite through the deployed
248    /// testing bundle — the agent/CI verb: a machine verdict without
249    /// the Designer (`ign webdev deploy --with-testing` or
250    /// `ign adopt --project NAME --testing` installs the bundle)
251    #[command(arg_required_else_help = true)]
252    Testing(TestingArgs),
253
254    /// JVM profiling — a SPECIAL MODE, not a routine diagnostic:
255    /// these verbs ACTIVELY instrument the gateway JVM (stack
256    /// sampling burns CPU; flight recordings buffer JVM memory and
257    /// dump to gateway disk). NEVER use against a production server
258    /// outside an approved change window with a rollback plan. The
259    /// intrusive verbs require --i-accept-the-risk — the global
260    /// --yes deliberately does NOT satisfy that gate
261    #[command(arg_required_else_help = true)]
262    Jvm(JvmArgs),
263
264    /// Lint local project files by delegating to ignition-lint (PATH
265    /// discovery) — doctor posture: findings are DATA, exit 0
266    /// whenever the tool ran; --strict passes the tool's exit code
267    /// through for CI
268    Lint(LintArgs),
269
270    /// Browser end-to-end testing: diagnose the host + gateway a
271    /// Playwright suite needs (`doctor`), and scaffold one that logs
272    /// in through `ign session login` and talks to the gateway's
273    /// testing routes (`init`). There is no `e2e run` — the
274    /// scaffold's own `npm test` is the runner
275    #[command(arg_required_else_help = true)]
276    E2e(E2eArgs),
277
278    /// Raw passthrough to any gateway REST endpoint — the escape
279    /// hatch for the uncurated routes (the envelope's `data` is left
280    /// gateway-verbatim; see README's documented contract exception)
281    #[command(arg_required_else_help = true)]
282    Api(ApiArgs),
283
284    /// License inventory + trial state — the morning-check read
285    /// (trial companion merged into one command)
286    #[command(arg_required_else_help = true)]
287    License(LicenseArgs),
288
289    /// Gateway redundancy status — the morning-check read
290    #[command(arg_required_else_help = true)]
291    Redundancy(RedundancyArgs),
292
293    /// Gateway Area Network (GAN) overview — the morning-check read
294    #[command(arg_required_else_help = true)]
295    Gan(GanArgs),
296
297    /// Diagnostics support-bundle operations: generate, check
298    /// status, download (streamed), wait — the support-bundle slice
299    #[command(arg_required_else_help = true)]
300    Diagnostics(DiagnosticsArgs),
301
302    /// Manage gateway profiles
303    #[command(arg_required_else_help = true)]
304    Profile(ProfileArgs),
305
306    /// Serve the Model Context Protocol over stdio (hidden; for MCP
307    /// clients — the newline-delimited JSON-RPC 2.0 stream IS the
308    /// stdout product; the routes.rs OutOfBand row lands atomically
309    /// with this command, 08-06 contract)
310    #[command(hide = true)]
311    Mcp(McpArgs),
312
313    /// Serve LSP over stdio (hidden; for ignition-nvim — the
314    /// Content-Length-framed LSP stream IS the stdout product; the
315    /// routes.rs OutOfBand row lands atomically with this command,
316    /// 08-06 contract)
317    #[command(hide = true)]
318    Lsp,
319
320    /// Interactive TUI cockpit
321    #[cfg(feature = "tui")]
322    Tui,
323
324    /// Live gateway monitor — htop for Ignition: cpu/heap gauges,
325    /// sparkline history, and a sortable/filterable table of the
326    /// gateway's live entities (modules, sessions, connections,
327    /// providers); terminate a session with `x` (the routes.rs
328    /// Dashboard row lands atomically with this command)
329    #[cfg(feature = "tui")]
330    #[command(alias = "itop")]
331    Top,
332}
333
334/// Hidden args for `ign mcp serve` (14-01). `serve` is a RESTRICTED
335/// POSITIONAL — not a subcommand — so the clap walk (tui_coverage.rs)
336/// yields exactly ONE row-requiring node, `mcp` itself: an `mcp
337/// serve` subcommand leaf would need a second routes() row and extend
338/// the pinned OutOfBand set past its four-member contract (an orphan
339/// row fails the walk by design). clap's `value_parser = ["serve"]`
340/// keeps every other spelling (`ign mcp`, `ign mcp bogus`) a
341/// usage-class clap error — exit 2 BEFORE any protocol byte is
342/// written. Zero stdout-facing behavior here — the mode owns its own
343/// I/O entirely (`mcp::serve`).
344#[derive(Debug, clap::Args)]
345pub struct McpArgs {
346    /// Start the stdio MCP server
347    #[arg(value_parser = ["serve"], value_name = "SERVE")]
348    pub serve: String,
349}
350
351/// Wait targets (02-05, HLTH-11). `gateway` and `restart` poll the
352/// UNAUTHENTICATED /StatusPing (the dispatch builds a header-less
353/// client for them — waiting must work when auth is broken); `module`
354/// is an authed read.
355#[derive(Debug, clap::Args)]
356pub struct WaitArgs {
357    #[command(subcommand)]
358    pub command: WaitCmd,
359}
360
361#[derive(Debug, Subcommand)]
362pub enum WaitCmd {
363    /// Wait until the gateway reports RUNNING (unauthenticated
364    /// StatusPing — works even when auth is broken or absent)
365    Gateway {
366        /// Poll interval in seconds
367        #[arg(long, default_value_t = 2, value_name = "SECS")]
368        interval: u64,
369        /// Give up after this many seconds
370        #[arg(long, default_value_t = 120, value_name = "SECS")]
371        timeout: u64,
372    },
373    /// Wait for a restart to complete — restart-aware: shares
374    /// `restart --wait`'s semantics (non-RUNNING observed once →
375    /// RUNNING; a 5 s floor guards the all-RUNNING case)
376    Restart {
377        /// Poll interval in seconds
378        #[arg(long, default_value_t = 2, value_name = "SECS")]
379        interval: u64,
380        /// Give up after this many seconds (default 300)
381        #[arg(long, default_value_t = 300, value_name = "SECS")]
382        timeout: u64,
383    },
384    /// Wait until a module reports ACTIVE
385    Module {
386        /// Module id (see `ign modules`)
387        id: String,
388        /// Poll interval in seconds
389        #[arg(long, default_value_t = 2, value_name = "SECS")]
390        interval: u64,
391        /// Give up after this many seconds
392        #[arg(long, default_value_t = 120, value_name = "SECS")]
393        timeout: u64,
394    },
395}
396
397/// Project subcommands (03-01, PROJ-01/02). `delete` is the family's
398/// ONE destructive verb (`--yes`-guarded, exit 2 without); copy/
399/// rename/set create or relabel — never destroy — so they carry NO
400/// guard (planner decision per research).
401#[derive(Debug, clap::Args)]
402pub struct ProjectArgs {
403    #[command(subcommand)]
404    pub command: ProjectCommand,
405}
406
407#[derive(Debug, Subcommand)]
408pub enum ProjectCommand {
409    /// List every runnable project: name, title, enabled, parent,
410    /// inheritable (inheritance info from the items themselves)
411    List,
412    /// Create a project (only provided fields ride the create body)
413    New {
414        /// Project name
415        name: String,
416        /// Display title
417        #[arg(long, value_name = "TEXT")]
418        title: Option<String>,
419        /// Long description
420        #[arg(long, value_name = "TEXT")]
421        description: Option<String>,
422        /// Parent project (inheritance)
423        #[arg(long, value_name = "NAME")]
424        parent: Option<String>,
425        /// Mark this project eligible as a parent
426        #[arg(long)]
427        inheritable: bool,
428        /// Create the project disabled
429        #[arg(long)]
430        disabled: bool,
431    },
432    /// Copy a project with all its resources
433    Copy {
434        /// Source project name
435        src: String,
436        /// Destination name (must not exist)
437        dst: String,
438    },
439    /// Rename a project (native rename, not copy+delete)
440    Rename {
441        /// Current name
442        old_name: String,
443        /// New name
444        new_name: String,
445    },
446    /// Set project fields — --parent IS the inheritance move (reparent)
447    #[command(group(
448        clap::ArgGroup::new("set_fields")
449            .required(true)
450            .multiple(true)
451            .args(&["title", "description", "parent", "set_enabled", "disabled", "inheritable"])
452    ))]
453    Set {
454        /// Project name
455        name: String,
456        /// Display title
457        #[arg(long, value_name = "TEXT")]
458        title: Option<String>,
459        /// Long description
460        #[arg(long, value_name = "TEXT")]
461        description: Option<String>,
462        /// Parent project (reparent)
463        #[arg(long, value_name = "NAME")]
464        parent: Option<String>,
465        /// Enable the project
466        #[arg(long, conflicts_with = "disabled")]
467        set_enabled: bool,
468        /// Disable the project
469        #[arg(long)]
470        disabled: bool,
471        /// Whether this project may serve as a parent (true/false)
472        #[arg(long, value_name = "BOOL")]
473        inheritable: Option<bool>,
474    },
475    /// Delete a project — destructive, refused without --yes
476    Delete {
477        /// Project name
478        name: String,
479    },
480    /// Export a project as a ZIP archive (streams to disk)
481    Export {
482        /// Project name
483        name: String,
484        /// Output file (default: the gateway's Content-Disposition
485        /// name, else <name>.zip); with --decode-scripts, the output
486        /// DIRECTORY (default <name>-export/)
487        #[arg(short, long, value_name = "FILE")]
488        output: Option<PathBuf>,
489        /// Also decode embedded JSON scripts (Perspective view.json
490        /// etc.) into editable `<member>.<n>.py` sidecars beside a
491        /// copied member tree + scripts-manifest.json — the output is
492        /// a DIRECTORY (script-python members are already plain .py
493        /// and never decode; expressions pass through)
494        #[arg(long)]
495        decode_scripts: bool,
496    },
497    /// Import a project from a ZIP archive
498    Import {
499        /// Project name to import as
500        name: String,
501        /// ZIP file path, or - to read the archive from stdin; with
502        /// --encode-scripts, a decoded export DIRECTORY (re-zipped
503        /// with the sidecars spliced back before upload; stdin is
504        /// invalid in that mode)
505        #[arg(long, value_name = "PATH")]
506        file: String,
507        /// Collision policy: abort refuses when the name exists
508        /// (default); overwrite REPLACES the entire project —
509        /// destructive, requires --yes. merge is Designer-only (not a
510        /// value; the README documents why)
511        #[arg(long, value_enum, default_value_t = CollisionPolicy::Abort)]
512        collision_policy: CollisionPolicy,
513        /// --file points at a decoded export DIRECTORY (from
514        /// `export --decode-scripts`): the sidecars are spliced back
515        /// and the manifest stripped before the standard import path
516        #[arg(long)]
517        encode_scripts: bool,
518    },
519    /// Compare a project across two gateway profiles — statuses are
520    /// B-relative-to-A (`added` = in B only, `removed` = in A only,
521    /// `changed` = differing after resource.json normalization)
522    Diff {
523        /// Baseline profile (A)
524        profile_a: String,
525        /// Compared profile (B — the diff is B relative to A)
526        profile_b: String,
527        /// Project name
528        #[arg(long, value_name = "NAME")]
529        project: String,
530    },
531    /// Promote selected resources from profile A into profile B
532    /// (direction is ALWAYS A→B) — destructive on B: the whole
533    /// project is overwrite-imported, refused without --yes
534    Sync {
535        /// Source profile (A)
536        profile_a: String,
537        /// Target profile (B)
538        profile_b: String,
539        /// Project name
540        #[arg(long, value_name = "NAME")]
541        project: String,
542        /// Resource user path to promote (repeatable)
543        #[arg(long, value_name = "PATH")]
544        resource: Vec<String>,
545        /// Promote every resource the diff reports added or changed
546        /// (never removed ones — deletion needs --delete)
547        #[arg(long)]
548        all_changed: bool,
549        /// Also remove B's resources the diff reports removed
550        /// (default: upsert-only, nothing is ever deleted)
551        #[arg(long)]
552        delete: bool,
553    },
554}
555
556/// Workspace subcommands (13-07): the local edit loop. `checkout`
557/// writes the mapped tree + records `.ign-workspace.json` (the
558/// manifest IS the workspace identity — status/push read it and the
559/// user never re-types the project); `status` reports PUSH-RELATIVE
560/// drift (every row says what push would do); `push` is the guarded
561/// splice of local edits into a FRESH gateway export.
562#[derive(Debug, clap::Args)]
563pub struct WorkspaceArgs {
564    #[command(subcommand)]
565    pub command: WorkspaceCommand,
566}
567
568#[derive(Debug, Subcommand)]
569pub enum WorkspaceCommand {
570    /// Check out a project's resources to a local tree — mapped
571    /// paths + the recorded `.ign-workspace.json` manifest + an
572    /// idempotent `.gitignore` (manifest committed; codec artifacts
573    /// ignored). Read-only on the wire: one export GET, zero imports.
574    Checkout {
575        /// Project name to check out
576        project: String,
577        /// Target directory for the workspace tree (created if
578        /// absent; an unrelated non-empty directory refuses — never
579        /// clobbered)
580        target: PathBuf,
581        /// Decode embedded JSON scripts to editable .py sidecars
582        /// (nvim-editable; encodes back cleanly — an unedited
583        /// re-encode is byte-exact; `scripts-manifest.json` keys the
584        /// splice)
585        #[arg(long)]
586        decode_scripts: bool,
587    },
588    /// Report workspace drift against the gateway — states are
589    /// PUSH-RELATIVE (each row names what push would do: write /
590    /// leave / refuse); the project comes from the workspace
591    /// manifest, never re-typed. Read-only: one export GET.
592    Status {
593        /// Workspace root (default: the current directory)
594        #[arg(default_value = ".")]
595        path: PathBuf,
596    },
597    /// Push local edits to the gateway — guarded (refused without
598    /// --yes; the refusal message IS the blast-radius preview);
599    /// conflicts refuse EVEN WITH --yes (manual reconciliation);
600    /// deletions need --delete (default: reported, skipped);
601    /// untracked files are never imported; an empty selection
602    /// writes nothing.
603    Push {
604        /// Workspace root (default: the current directory)
605        #[arg(default_value = ".")]
606        path: PathBuf,
607        /// Also delete gateway members removed locally (default:
608        /// reported, skipped)
609        #[arg(long)]
610        delete: bool,
611    },
612}
613
614/// `ign deploy` args (21-04): the declared gateway deploy family.
615#[derive(Debug, clap::Args)]
616pub struct DeployArgs {
617    #[command(subcommand)]
618    pub command: DeployCommand,
619}
620
621#[derive(Debug, Subcommand)]
622pub enum DeployCommand {
623    /// Render `gateways/<name>/` for one environment into the bytes
624    /// the gateway holds on disk — offline (no profile, no network,
625    /// no credentials). Reports file and byte counts and the
626    /// `render_sha256` digest; refuses an invalid tree with
627    /// `deploy_tree_invalid`
628    Render {
629        /// Environment name; selects `targets/<ENV>.yaml`
630        #[arg(long, value_name = "ENV")]
631        env: String,
632        /// Declared repo root (default: the current directory)
633        #[arg(long, value_name = "DIR", default_value = ".")]
634        repo: PathBuf,
635        /// Directory holding `targets/<ENV>.yaml` and the target's
636        /// known_hosts files (default: --repo)
637        #[arg(long, value_name = "DIR")]
638        targets: Option<PathBuf>,
639        /// Also write the rendered tree and a `render.json` marker to
640        /// this directory: absent, empty, or a previous render of the
641        /// same gateway and environment. Never deletes; anything else
642        /// is refused (`invalid_input`) before any write
643        #[arg(long, value_name = "DIR")]
644        out: Option<PathBuf>,
645    },
646    /// Plan one environment: compare the rendered tree with the master
647    /// node's live `config/resources/` files and report each unit as
648    /// added, changed, removed, same or unmanaged. Read-only; exits 0
649    /// whether or not anything changed. Reads the host through the
650    /// master's `ssh` or `local` block; resolves no gateway session
651    Plan {
652        /// Environment name; selects `targets/<ENV>.yaml`
653        #[arg(long, value_name = "ENV")]
654        env: String,
655        /// Declared repo root (default: the current directory)
656        #[arg(long, value_name = "DIR", default_value = ".")]
657        repo: PathBuf,
658        /// Directory holding `targets/<ENV>.yaml` and the target's
659        /// known_hosts files (default: --repo)
660        #[arg(long, value_name = "DIR")]
661        targets: Option<PathBuf>,
662        /// A directory written by `deploy render --out`: a previous render
663        /// of this gateway, environment and mode. It is re-hashed, never
664        /// trusted; a base that is modified, incomplete or of another
665        /// gateway is refused (`invalid_input`). Each entry then carries
666        /// `drift`: true when a live file differs from both the base and
667        /// the desired tree
668        #[arg(long, value_name = "DIR")]
669        base: Option<PathBuf>,
670        /// Add a unified text diff (live against desired, 3 lines of
671        /// context, at most 65536 bytes per unit) to added, changed and
672        /// removed entries. Members that hold, or may hold, a secret are
673        /// withheld and print only their sha256 values; non-JSON members
674        /// print only that they differ
675        #[arg(long)]
676        diff: bool,
677    },
678    /// Bootstrap `gateways/<name>/` from the master node's live tree.
679    /// Read-only against the gateway: it reads the host through the
680    /// master's `ssh` or `local` block and writes only the local repo.
681    /// `core` files go to `resources/` and `tags/`, the target mode
682    /// collection's files to `modes/<ENV>/`. Units that hold a secret or
683    /// that render would not reproduce byte for byte are skipped and listed
684    /// with a reason; the next `plan` reports them as unmanaged. Replacing a
685    /// non-empty `resources/`, `tags/` or `modes/<ENV>/` needs the global
686    /// `--yes`; `manifest.yaml` and other environments are never touched
687    Pull {
688        /// Environment name; selects `targets/<ENV>.yaml`
689        #[arg(long, value_name = "ENV")]
690        env: String,
691        /// Declared repo root (default: the current directory)
692        #[arg(long, value_name = "DIR", default_value = ".")]
693        repo: PathBuf,
694        /// Directory holding `targets/<ENV>.yaml` and the target's
695        /// known_hosts files (default: --repo)
696        #[arg(long, value_name = "DIR")]
697        targets: Option<PathBuf>,
698    },
699    /// Verify that the master gateway runs what the target declares:
700    /// no scan running, the target's deployment mode, every declared
701    /// resource read back equal over REST, tag-unit files byte-equal on the
702    /// host, no `will not be loaded` WARN for a declared resource, and the
703    /// manifest's tag probes reading Good. Read-only: no scan is triggered,
704    /// no lock is taken, nothing is written. Resolves the master's profile
705    /// through the normal secret chain. Prints a verdict: exit 0 when green,
706    /// exit 6 with `data.slug` `deploy_verify_failed` when red
707    Verify {
708        /// Environment name; selects `targets/<ENV>.yaml`
709        #[arg(long, value_name = "ENV")]
710        env: String,
711        /// Declared repo root (default: the current directory)
712        #[arg(long, value_name = "DIR", default_value = ".")]
713        repo: PathBuf,
714        /// Directory holding `targets/<ENV>.yaml` and the target's
715        /// known_hosts files (default: --repo)
716        #[arg(long, value_name = "DIR")]
717        targets: Option<PathBuf>,
718        /// The project hosting the WebDev `tags` route used for the tag
719        /// probes
720        #[arg(long, value_name = "NAME", default_value = "ign-cli")]
721        project: String,
722        /// Which node to verify: `master` (the default) runs the six master
723        /// checks; `backup` runs only the backup node's checks (redundancy
724        /// linked, mode, REST read-back, files against the master's live
725        /// copy, and the secret names the render references); `both` runs
726        /// the master's checks, then the backup's. Needs `nodes.backup` for
727        /// `backup` and `both`. Verify never waits, never resyncs and never
728        /// writes
729        #[arg(long, value_enum, value_name = "NODE", default_value = "master")]
730        node: VerifyNodeArg,
731    },
732    /// Apply one environment: write the declared tree into the master
733    /// node's mode collection under the gateway scan-lock, scan, then verify
734    /// it; a red verify restores the pre-apply snapshot automatically
735    /// (`data.outcome` `rolled_back`, `data.slug` `deploy_rolled_back`, exit
736    /// 6). When the target has `nodes.backup`, the apply then waits for the
737    /// backup node to converge (`--backup-timeout`), sends at most one resync
738    /// to the master, and reports `data.backup`; a backup that cannot be
739    /// proven ends `data.outcome` `applied_backup_unsynced`, `data.slug`
740    /// `deploy_backup_unsynced`, exit 6, with the master left applied.
741    /// Guarded: without the global `--yes` it exits 2
742    /// `confirmation_required` and the message IS the plan (the changing
743    /// entries, the delete-set count and `plan_sha256`). `--plan-sha256`
744    /// binds the apply to the plan that was reviewed: a changed plan exits 6
745    /// `plan_stale`, and with `--json` or without a terminal the flag is
746    /// required. Takes a `.gwbk` backup first (`--skip-gwbk` records that it
747    /// did not). One fixed lock hold serves every target; a change of 1,528 or
748    /// more files (writes plus deletes) is refused by the `hold_budget`
749    /// preflight. There is no flag that skips the plan hash, the verify or
750    /// the rollback. Staging a large tree is silent on the wire and can take
751    /// minutes; in human mode progress lines go to stderr
752    Apply {
753        /// Environment name; selects `targets/<ENV>.yaml`
754        #[arg(long, value_name = "ENV")]
755        env: String,
756        /// Declared repo root (default: the current directory)
757        #[arg(long, value_name = "DIR", default_value = ".")]
758        repo: PathBuf,
759        /// Directory holding `targets/<ENV>.yaml` and the target's
760        /// known_hosts files (default: --repo)
761        #[arg(long, value_name = "DIR")]
762        targets: Option<PathBuf>,
763        /// A directory written by `deploy render --out`: a previous render
764        /// of this gateway, environment and mode. Enables the drift gate: a
765        /// live file that differs from both the base and the desired tree
766        /// refuses the apply (`destructive_change_refused`, exit 2) unless
767        /// `--allow-drift` gives a reason. An entry that a `core` copy
768        /// shadows is refused whatever the flags (`deploy_blocked`, exit 6)
769        #[arg(long, value_name = "DIR")]
770        base: Option<PathBuf>,
771        /// The project hosting the WebDev `tags` route the post-apply verify
772        /// uses for the tag probes, as in `deploy verify`
773        #[arg(long, value_name = "NAME", default_value = "ign-cli")]
774        project: String,
775        /// The `plan_sha256` of the plan that was reviewed. A different
776        /// current plan refuses the apply (`plan_stale`, exit 6). Required
777        /// with `--json` or when stdin or stdout is not a terminal
778        #[arg(long, value_name = "HASH")]
779        plan_sha256: Option<String>,
780        /// Allow a plan that deletes or replaces something destructive; the
781        /// plan lists each such entry. Without it the apply is refused
782        /// (`destructive_change_refused`, exit 2)
783        #[arg(long)]
784        allow_destructive: bool,
785        /// Go ahead over drifted entries (live changed since the `--base`
786        /// render) and record this reason in the run record (at most 500
787        /// characters)
788        #[arg(long, value_name = "REASON")]
789        allow_drift: Option<String>,
790        /// What to do about open Designer sessions: `fail` refuses while any
791        /// is open, `wait` polls until none is or `--designers-timeout`
792        /// passes, `proceed` goes ahead and records the sessions
793        #[arg(long, value_enum, value_name = "MODE", default_value = "fail")]
794        designers: DesignersArg,
795        /// Seconds `--designers wait` polls before it refuses
796        #[arg(
797            long,
798            value_name = "SECS",
799            default_value_t = 600,
800            value_parser = clap::value_parser!(u64).range(1..=86400)
801        )]
802        designers_timeout: u64,
803        /// Take no `.gwbk` backup; the run record states that none was taken
804        #[arg(long)]
805        skip_gwbk: bool,
806        /// Seconds to wait for the backup node to converge after the master
807        /// is verified; then one resync is sent to the master and the wait
808        /// repeats once. Only used when the target has nodes.backup
809        #[arg(
810            long,
811            value_name = "SECS",
812            default_value_t = 300,
813            value_parser = clap::value_parser!(u64).range(1..=86400)
814        )]
815        backup_timeout: u64,
816    },
817    /// Roll back one past run by id, from a fresh process: restore the
818    /// on-box snapshot the run took into the master node's mode collection,
819    /// delete the files the run created, scan, and record the rollback (a
820    /// run directory of its own, `kind: rollback`). The run id is
821    /// `data.run_id` of the `deploy apply` that made it, or its run
822    /// directory's name. Also recovers an interrupted run: a refusal that
823    /// prints `ign deploy rollback --run <id> --env <env>` names the id to
824    /// pass. Guarded: without the global `--yes` it exits 2
825    /// `confirmation_required` and the message names the run, the gateway,
826    /// the mode and how many files it would restore and delete. A file
827    /// changed since the run refuses (`deploy_live_changed`, exit 6) unless
828    /// `--discard-live-changes`. One fixed lock hold (176 s) serves the
829    /// rollback. Not in the MCP catalog. In human mode progress lines go to
830    /// stderr
831    Rollback {
832        /// Environment name; selects `targets/<ENV>.yaml`
833        #[arg(long, value_name = "ENV")]
834        env: String,
835        /// Declared repo root (default: the current directory). Only the
836        /// target file is read; the tree is not rendered
837        #[arg(long, value_name = "DIR", default_value = ".")]
838        repo: PathBuf,
839        /// Directory holding `targets/<ENV>.yaml` and the target's
840        /// known_hosts files (default: --repo)
841        #[arg(long, value_name = "DIR")]
842        targets: Option<PathBuf>,
843        /// The run to roll back: the `run_id` that `deploy apply` printed
844        /// (or its directory below `.ign/deploy/<gateway>/`)
845        #[arg(long, value_name = "ID")]
846        run: String,
847        /// Overwrite files changed after that run and record which. Without
848        /// it a changed file refuses the rollback (`deploy_live_changed`,
849        /// exit 6)
850        #[arg(long)]
851        discard_live_changes: bool,
852        /// What to do about open Designer sessions: `fail` refuses while any
853        /// is open, `wait` polls until none is or `--designers-timeout`
854        /// passes, `proceed` goes ahead and records the sessions
855        #[arg(long, value_enum, value_name = "MODE", default_value = "fail")]
856        designers: DesignersArg,
857        /// Seconds `--designers wait` polls before it refuses
858        #[arg(
859            long,
860            value_name = "SECS",
861            default_value_t = 600,
862            value_parser = clap::value_parser!(u64).range(1..=86400)
863        )]
864        designers_timeout: u64,
865    },
866    /// Deploy one Ignition project from its own repo to a gateway. Reads
867    /// `deploy/project.yaml` (the gateway project name and the environments
868    /// it deploys to) and the root `project.json`, reads `targets/<ENV>.yaml`,
869    /// builds the artifact from the checkout and compares it with the live
870    /// project. `--check` reports `artifact_sha256`, `live_sha256`, the action
871    /// (`create` for a project absent from the gateway, `skip` when the live
872    /// project equals the artifact and this release is recorded, `record` when
873    /// it equals the artifact but no record names this release, `import`
874    /// otherwise), the member counts and the live members an overwrite would
875    /// delete, over GET requests only; it writes nothing and creates no
876    /// `.ign/` directory. Without `--check` the deploy asks for confirmation
877    /// (`--yes`), requires `--artifact-sha256` (the hash the check reported)
878    /// for an unattended run, saves the live project under `.ign/deploy/`,
879    /// imports the artifact, reads the project back to compare it by
880    /// canonical hash and records the release on the gateway host. Other
881    /// projects on the gateway are listed by name and never read or changed.
882    /// Not in the MCP catalog
883    Projects {
884        /// Environment name; must be listed in `deploy/project.yaml` and
885        /// selects `targets/<ENV>.yaml`
886        #[arg(long, value_name = "ENV")]
887        env: String,
888        /// The release tag being deployed: semver after one optional leading
889        /// `v` (for example v1.4.0). A pre-release is older than its release
890        #[arg(long, value_name = "TAG")]
891        release: String,
892        /// The project repo root (default: the current directory)
893        #[arg(long, value_name = "DIR", default_value = ".")]
894        repo: PathBuf,
895        /// A root holding `targets/<ENV>.yaml` (default: `--repo`). Gateway
896        /// targets may live in another repository than the project
897        #[arg(long, value_name = "DIR")]
898        targets: Option<PathBuf>,
899        /// Report what would change and write nothing; no confirmation and
900        /// no `--artifact-sha256` needed
901        #[arg(long)]
902        check: bool,
903        /// The `artifact_sha256` that `--check --json` reported for this
904        /// checkout (64 lowercase hex characters). Required without `--check`
905        /// with `--json`, `--compact` or a stdin or stdout that is not a
906        /// terminal; a different artifact is refused before anything is
907        /// uploaded
908        #[arg(long, value_name = "HASH")]
909        artifact_sha256: Option<String>,
910        /// Let a release older than the one the gateway last received from
911        /// this tool replace it. Without it that is refused as a downgrade
912        #[arg(long)]
913        allow_downgrade: bool,
914        /// Let the first deploy of a project by this tool change what the
915        /// gateway runs, with a reason of 1 to 500 characters. Without it the
916        /// release must equal the live project
917        #[arg(long, value_name = "REASON")]
918        allow_first_change: Option<String>,
919        /// What to do about open Designer sessions before an import: `fail`
920        /// refuses while any is open, `wait` polls until none is or
921        /// `--designers-timeout` passes, `proceed` goes ahead and records
922        /// the sessions. A release-record-only run skips the check
923        #[arg(long, value_enum, value_name = "MODE", default_value = "fail")]
924        designers: DesignersArg,
925        /// Seconds `--designers wait` polls before it refuses
926        #[arg(
927            long,
928            value_name = "SECS",
929            default_value_t = 600,
930            value_parser = clap::value_parser!(u64).range(1..=86400)
931        )]
932        designers_timeout: u64,
933    },
934}
935
936/// The `--node` words of `deploy verify` (D-12).
937#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
938pub enum VerifyNodeArg {
939    /// The six master checks (the default)
940    Master,
941    /// Only the backup node's checks
942    Backup,
943    /// The master's checks, then the backup's
944    Both,
945}
946
947/// The `--designers` words of `deploy apply`, `deploy rollback` and
948/// `deploy projects` (D-14, D-15).
949#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
950pub enum DesignersArg {
951    /// Refuse while any Designer session is open (the default)
952    Fail,
953    /// Poll until none is open or the timeout passes, then refuse
954    Wait,
955    /// Go ahead, and record the open sessions
956    Proceed,
957}
958
959/// `ign edit` args (13-08): the kubectl-edit loop over ONE resource.
960/// The resource path scopes what $EDITOR opens (the whole tree still
961/// decodes for encode-back context); the push rides the
962/// project-import wire behind the ONE confirmation gate. `--yes` is
963/// the GLOBAL flag (globals-once) — never redeclared here.
964#[derive(Debug, clap::Args)]
965pub struct EditArgs {
966    /// Project containing the resource
967    pub project: String,
968    /// Resource user path to open in $EDITOR (e.g.
969    /// `ignition/script-python/e2e/scratch` — `ign resource list`
970    /// names the valid members)
971    pub resource_path: String,
972}
973
974/// Resource subcommands (03-03, PROJ-05). `delete` is the family's
975/// destructive verb (`--yes`-guarded, exit 2 without — the
976/// sessions-terminate shape); `put` is an upsert (create-or-replace
977/// ONE resource with explicit content) and stays friction-free per
978/// the planner decision — agents pass exactly what they want written.
979#[derive(Debug, clap::Args)]
980pub struct ResourceArgs {
981    #[command(subcommand)]
982    pub command: ResourceCommand,
983}
984
985#[derive(Debug, Subcommand)]
986pub enum ResourceCommand {
987    /// List a project's resources (one path per line in human mode)
988    List {
989        /// Project name
990        project: String,
991        /// Only paths under this prefix (rides the wire as the
992        /// server-side `path` filter)
993        #[arg(long, value_name = "PREFIX")]
994        prefix: Option<String>,
995    },
996    /// Read one resource: JSON pretty-printed, text raw — binary
997    /// (data.bin-class) resources refuse with exit 6
998    Get {
999        /// Project name
1000        project: String,
1001        /// Resource path, slashes kept (e.g.
1002        /// `ignition/script-python/e2e/scratch`)
1003        path: String,
1004    },
1005    /// Write one resource (upsert: created if absent, replaced if
1006    /// present) — JSON if parseable (application/json), else UTF-8
1007    /// text (text/plain); binary-looking input refuses
1008    Put {
1009        /// Project name
1010        project: String,
1011        /// Resource path
1012        path: String,
1013        /// File to read the content from, or `-` for stdin
1014        #[arg(long, value_name = "PATH")]
1015        file: String,
1016    },
1017    /// Delete one resource — destructive, refused without --yes
1018    Delete {
1019        /// Project name
1020        project: String,
1021        /// Resource path
1022        path: String,
1023    },
1024}
1025
1026/// Webdev subcommands (05-03, WEB-01/02). Deploy is deliberately NOT
1027/// `--yes`-guarded: the dedicated project (default `ign-cli`) is
1028/// CLI-OWNED — born from the first deploy zip and overwrite-replaced
1029/// on every deploy (replace-not-merge is the CONTRACT here; user
1030/// projects are never touched — README documents). scriptExec rides
1031/// only on explicit request, gated by a deploy-time generated shared
1032/// secret stored in the profile config at 0600.
1033#[derive(Debug, clap::Args)]
1034pub struct WebdevArgs {
1035    #[command(subcommand)]
1036    pub command: WebdevCommand,
1037}
1038
1039#[derive(Debug, Subcommand)]
1040pub enum WebdevCommand {
1041    /// Deploy the embedded route bundle into the dedicated project
1042    /// (overwrite-replace — the CLI owns that project wholesale)
1043    Deploy {
1044        /// Target project (default ign-cli; the CLI owns it wholesale)
1045        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1046        project: String,
1047        /// Also deploy the secret-gated scriptExec route (a fresh
1048        /// secret is generated and stored at 0600 when none exists)
1049        #[arg(long)]
1050        with_script_exec: bool,
1051        /// Generate a FRESH scriptExec secret before deploying (any
1052        /// route deployed with the old secret starts refusing)
1053        #[arg(long, requires = "with_script_exec")]
1054        rotate_secret: bool,
1055        /// Also deploy the embedded TESTING bundle — the Jython test
1056        /// framework (testing.runner & co., with the permanent
1057        /// testing.__tests__ smoke sentinel) and the testing/run +
1058        /// testing/tags WebDev routes
1059        #[arg(long)]
1060        with_testing: bool,
1061    },
1062    /// Probe every route's version handshake — a READ: exit 0
1063    /// whenever the sweep completes, per-route degradation is data
1064    Status {
1065        /// Target project (default ign-cli)
1066        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1067        project: String,
1068    },
1069}
1070
1071/// Tags args (05-04, TAGS-01..04) — the grouped-subfamily pattern:
1072/// `tags provider …` nests one level deeper (the family's native
1073/// REST half), browse/read/write ride the TOP level. The
1074/// webdev-dependent arms carry `--project` (default `ign-cli`, the
1075/// same default as deploy — the deployed routes live in that
1076/// project).
1077#[derive(Debug, clap::Args)]
1078pub struct TagsArgs {
1079    #[command(subcommand)]
1080    pub command: TagsCommand,
1081}
1082
1083#[derive(Debug, Subcommand)]
1084pub enum TagsCommand {
1085    /// Manage tag providers (native config-resource REST — no
1086    /// deployed routes involved): list with tag counts + health,
1087    /// create a STANDARD provider, delete (guarded)
1088    #[command(subcommand)]
1089    Provider(TagsProviderCommand),
1090
1091    /// Browse tags as a tree (Property children filtered by
1092    /// default) — providers appear at the root; needs the deployed
1093    /// routes (`ign webdev deploy`) — or run fully OFFLINE against
1094    /// an export via --from-export
1095    Browse {
1096        /// Tag path to browse from (default: the root — providers)
1097        path: Option<String>,
1098        /// Case-insensitive substring filter on name and full path
1099        #[arg(long, value_name = "SUBSTR")]
1100        filter: Option<String>,
1101        /// Include Property children (filtered out by default)
1102        #[arg(long)]
1103        include_properties: bool,
1104        /// Project holding the deployed routes (default ign-cli)
1105        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1106        project: String,
1107        /// Browse an OFFLINE tag export instead of the gateway (no
1108        /// profile, no credential, no routes): the CLI's own `tags
1109        /// export` JSON, a legacy `<provider>.json` whole tree, or a
1110        /// git-module tags/ directory — mutually exclusive with the
1111        /// positional browse path
1112        #[arg(long, value_name = "PATH", conflicts_with_all = ["path"])]
1113        from_export: Option<PathBuf>,
1114    },
1115    /// Read one or more tag values (quality and timestamp included)
1116    /// — needs the deployed routes
1117    Read {
1118        /// Tag paths to read, e.g. `[default]T1` (one or more)
1119        #[arg(value_name = "PATH", required = true)]
1120        paths: Vec<String>,
1121        /// Project holding the deployed routes (default ign-cli)
1122        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1123        project: String,
1124    },
1125    /// Write a value to a tag — the value is parsed as a JSON
1126    /// scalar (number/bool/null); anything unparseable is sent as a
1127    /// string; arrays/objects refuse — needs the deployed routes
1128    Write {
1129        /// Tag path to write, e.g. `[default]T1`
1130        path: String,
1131        /// Value to write: JSON scalar (42, 1.5, true), else the raw
1132        /// text is sent as a string
1133        #[arg(long, value_name = "V")]
1134        value: String,
1135        /// Project holding the deployed routes (default ign-cli)
1136        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1137        project: String,
1138    },
1139    /// Tag CONFIGURATION CRUD (the surgical edit loop — JSON in,
1140    /// JSON out): get/create/edit/delete tag configs through the
1141    /// deployed tagConfig route; stringified values are re-parsed so
1142    /// agents see real JSON
1143    #[command(subcommand)]
1144    Config(TagsConfigCommand),
1145    /// UDT types and definitions (recursive: parameters + nested
1146    /// children) — needs the deployed routes
1147    #[command(subcommand)]
1148    Udt(TagsUdtCommand),
1149    /// Alarm operations: active status (with filters), journal
1150    /// history, acknowledge — needs the deployed routes
1151    #[command(subcommand)]
1152    Alarms(TagsAlarmsCommand),
1153    /// Tag history queries (historian-backed values) — needs the
1154    /// deployed routes; structurally safe on any rig (data requires
1155    /// a provisioned historian)
1156    #[command(subcommand)]
1157    History(TagsHistoryCommand),
1158    /// Export tag subtrees — json writes the gateway's native JSON
1159    /// interchange (the lossless round-trip); xml passes the
1160    /// gateway's own XML through byte-for-byte; csv is CLI-GENERATED
1161    /// and LOSSY (no alarms, legacy 48-column set, numeric enums —
1162    /// the gateway cannot export CSV)
1163    Export {
1164        /// Tag paths to export, e.g. `[default]P5` (one or more)
1165        #[arg(value_name = "PATH", required = true)]
1166        paths: Vec<String>,
1167        /// Output file (`-` = stdout — the raw payload, no envelope;
1168        /// default: `<last-path-segment>.<ext>` in the cwd, ext per
1169        /// format)
1170        #[arg(short = 'o', long, value_name = "FILE")]
1171        output: Option<PathBuf>,
1172        /// Payload format: json (the gateway's native interchange —
1173        /// default), xml (raw gateway bytes), or csv (CLI-generated
1174        /// and LOSSY: no alarms, legacy columns only, numeric enum
1175        /// coercion — what was dropped prints on stderr)
1176        #[arg(long, value_enum, default_value_t = TransferFormat::Json, value_name = "FORMAT")]
1177        format: TransferFormat,
1178        /// Project holding the deployed routes (default ign-cli)
1179        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1180        project: String,
1181    },
1182    /// Import a tag export into a target provider — json is the
1183    /// native interchange; xml/csv ride the gateway's importTags
1184    /// passthrough. A loss scan reports what xml/csv would drop or
1185    /// coerce BEFORE the import and refuses exit 2 unless --yes.
1186    /// Collision behavior: abort (default) refuses on collisions;
1187    /// overwrite replaces them (destructive: requires --yes)
1188    Import {
1189        /// The export file to import (`-` = stdin)
1190        #[arg(long, value_name = "FILE")]
1191        file: PathBuf,
1192        /// Target tag provider (must exist — `ign tags provider
1193        /// create NAME` first)
1194        #[arg(long, value_name = "NAME")]
1195        provider: String,
1196        /// Payload format: json (the gateway's native interchange —
1197        /// default), xml (gateway XML), or csv (legacy CSV — alarms
1198        /// never arrive; the loss scan reports the gap)
1199        #[arg(long, value_enum, default_value_t = TransferFormat::Json, value_name = "FORMAT")]
1200        format: TransferFormat,
1201        /// Collision policy: abort refuses when tags already exist
1202        /// (default); overwrite replaces them (destructive:
1203        /// requires --yes). merge is Designer-only (not a value)
1204        #[arg(long, value_enum, default_value_t = CollisionPolicy::Abort)]
1205        collision_policy: CollisionPolicy,
1206        /// Project holding the deployed routes (default ign-cli)
1207        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1208        project: String,
1209    },
1210}
1211
1212/// The bulk-transfer payload format (11-05) — the clap surface for
1213/// core's `ExportFormat`/`ImportFormat` (core keeps plain enums; the
1214/// From impls live beside this one, the CollisionPolicy precedent).
1215/// json is the default so every pre-11-05 invocation parses and
1216/// behaves byte-identically.
1217#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
1218pub enum TransferFormat {
1219    /// The gateway's JSON interchange (default — lossless)
1220    #[default]
1221    Json,
1222    /// Gateway XML — RAW-BYTE passthrough both directions (no
1223    /// parse/normalize in the transfer path)
1224    Xml,
1225    /// Legacy CSV — import: gateway passthrough (alarms never
1226    /// arrive); export: CLI-GENERATED and LOSSY (the gateway cannot
1227    /// export CSV)
1228    Csv,
1229}
1230
1231impl std::fmt::Display for TransferFormat {
1232    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1233        let label = match self {
1234            Self::Json => "json",
1235            Self::Xml => "xml",
1236            Self::Csv => "csv",
1237        };
1238        f.write_str(label)
1239    }
1240}
1241
1242/// `tags config …` — the configuration CRUD subfamily (05-05,
1243/// TAGS-05): the get→edit-file→write-back surgical loop. Definition
1244/// files are the configure shape (README's traps table: tagType
1245/// discriminator, nested children, alarms-as-LIST).
1246#[derive(Debug, Subcommand)]
1247pub enum TagsConfigCommand {
1248    /// Get a tag's configuration as (pretty) JSON — stringified
1249    /// value/defaultValue sub-dicts re-parsed into real JSON
1250    Get {
1251        /// Tag path, e.g. `[default]P5/T1`
1252        path: String,
1253        /// Project holding the deployed routes (default ign-cli)
1254        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1255        project: String,
1256    },
1257    /// Create a tag from a JSON definition file (`-` = stdin) —
1258    /// aborts on an existing node (collision policy 'a')
1259    Create {
1260        /// Tag path to create, e.g. `[default]P5/T1`
1261        path: String,
1262        /// JSON definition file (`-` = stdin): `{tagType, value,
1263        /// alarms, …}` — see the README configure-shape table
1264        #[arg(long, value_name = "FILE")]
1265        file: PathBuf,
1266        /// Project holding the deployed routes (default ign-cli)
1267        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1268        project: String,
1269    },
1270    /// Edit a tag's configuration from a JSON definition file (`-` =
1271    /// stdin) — overwrites that single node (collision policy 'o')
1272    Edit {
1273        /// Tag path to edit, e.g. `[default]P5/T1`
1274        path: String,
1275        /// JSON definition file (`-` = stdin)
1276        #[arg(long, value_name = "FILE")]
1277        file: PathBuf,
1278        /// Project holding the deployed routes (default ign-cli)
1279        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1280        project: String,
1281    },
1282    /// Delete tag configurations — destructive, refused without
1283    /// --yes (the guard fires before ANY resolution: zero network
1284    /// work)
1285    Delete {
1286        /// Tag paths to delete (one or more)
1287        #[arg(value_name = "PATH", required = true)]
1288        paths: Vec<String>,
1289        /// Project holding the deployed routes (default ign-cli)
1290        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1291        project: String,
1292    },
1293}
1294
1295/// `tags udt …` — the UDT subfamily (05-05, TAGS-06).
1296#[derive(Debug, Subcommand)]
1297pub enum TagsUdtCommand {
1298    /// List the provider's UDT types
1299    Types {
1300        /// Tag provider whose `_types_` folder to browse (default
1301        /// `default`)
1302        #[arg(long, default_value = "default", value_name = "NAME")]
1303        provider: String,
1304        /// Project holding the deployed routes (default ign-cli)
1305        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1306        project: String,
1307    },
1308    /// Get a UDT definition (parameters + nested children, recursive)
1309    Def {
1310        /// UDT type name, e.g. `Motor`
1311        name: String,
1312        /// Tag provider (default `default`)
1313        #[arg(long, default_value = "default", value_name = "NAME")]
1314        provider: String,
1315        /// Project holding the deployed routes (default ign-cli)
1316        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1317        project: String,
1318    },
1319}
1320
1321#[derive(Debug, Subcommand)]
1322pub enum TagsProviderCommand {
1323    /// List the gateway's tag providers (tag counts + health)
1324    List,
1325    /// Create a STANDARD tag provider (DB-backed providers are out
1326    /// of scope at MVP)
1327    Create {
1328        /// Provider name to create
1329        name: String,
1330    },
1331    /// Delete a tag provider — destructive, refused without --yes
1332    Delete {
1333        /// Provider name to delete
1334        name: String,
1335    },
1336}
1337
1338/// `tags alarms …` — the alarm subfamily (05-06, TAGS-07). History
1339/// needs a JOURNAL-PROVISIONED gateway (database connection +
1340/// alarm-journal profile + general-alarm-settings — default rigs
1341/// refuse exit 6 with the provisioning hint); acknowledge is the
1342/// gateway-scope 3-arg form, so `--username` is REQUIRED (no
1343/// default-guessing) and ack is deliberately NOT `--yes`-guarded
1344/// (acknowledging never un-acknowledges anything — a state-advancing
1345/// read-adjacent verb).
1346#[derive(Debug, Subcommand)]
1347pub enum TagsAlarmsCommand {
1348    /// List active alarms — eventId/source/state/priority/name
1349    Active {
1350        /// Filter by alarm source (e.g. `prov:default`)
1351        #[arg(long, value_name = "SOURCE")]
1352        source: Option<String>,
1353        /// Filter by priority (e.g. `High`)
1354        #[arg(long, value_name = "PRIORITY")]
1355        priority: Option<String>,
1356        /// Filter by state (e.g. `Active, Unacknowledged`)
1357        #[arg(long, value_name = "STATE")]
1358        state: Option<String>,
1359        /// Project holding the deployed routes (default ign-cli)
1360        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1361        project: String,
1362    },
1363    /// Query alarm history — requires a journal-provisioned gateway
1364    /// (default rigs refuse with the provisioning hint naming the
1365    /// missing chain)
1366    History {
1367        /// Window start: RFC3339 timestamp or epoch-ms
1368        #[arg(long, value_name = "T")]
1369        start: String,
1370        /// Window end: RFC3339 timestamp or epoch-ms
1371        #[arg(long, value_name = "T")]
1372        end: String,
1373        /// Project holding the deployed routes (default ign-cli)
1374        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1375        project: String,
1376    },
1377    /// Acknowledge alarms (explicit --username: the 3-arg wire form
1378    /// needs it). NOT --yes-guarded — acknowledging never
1379    /// un-acknowledges anything
1380    Ack {
1381        /// Alarm event ids to acknowledge (from `alarms active`)
1382        #[arg(value_name = "ID", required = true)]
1383        ids: Vec<String>,
1384        /// Acknowledgement note (default: empty)
1385        #[arg(long, value_name = "NOTE")]
1386        note: Option<String>,
1387        /// The username acknowledging (REQUIRED — the 3-arg wire
1388        /// form needs it; the CLI never guesses one)
1389        #[arg(long, value_name = "NAME")]
1390        username: String,
1391        /// Project holding the deployed routes (default ign-cli)
1392        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1393        project: String,
1394    },
1395}
1396
1397/// `tags history …` — the historian subfamily (05-06, TAGS-08).
1398#[derive(Debug, Subcommand)]
1399pub enum TagsHistoryCommand {
1400    /// Query historical tag values — t_stamp + one column per tag;
1401    /// structurally safe anywhere (data requires a provisioned
1402    /// historian)
1403    Query {
1404        /// Tag paths to query, e.g. `[default]T1` (one or more)
1405        #[arg(value_name = "PATH", required = true)]
1406        paths: Vec<String>,
1407        /// Window start: RFC3339 timestamp or epoch-ms
1408        #[arg(long, value_name = "T")]
1409        start: String,
1410        /// Window end: RFC3339 timestamp or epoch-ms
1411        #[arg(long, value_name = "T")]
1412        end: String,
1413        /// Maximum rows returned
1414        #[arg(long, value_name = "N")]
1415        return_size: Option<i64>,
1416        /// Aggregation mode (e.g. `LastValue`, `Average`, `MinMax`);
1417        /// default is the route's LastValue
1418        #[arg(long, value_name = "MODE")]
1419        aggregation: Option<String>,
1420        /// Project holding the deployed routes (default ign-cli)
1421        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1422        project: String,
1423    },
1424}
1425
1426/// Rig args (04-01, RIG-01): `--rig` rides the TOP level (the
1427/// SessionsArgs `--type` precedent) so every rig verb shares it. The
1428/// command enum carries ONLY the wired verbs — later plans extend it
1429/// one variant at a time (the established extend-per-plan chore; no
1430/// `unimplemented!()` stubs, every commit compiles clippy-clean).
1431#[derive(Debug, clap::Args)]
1432pub struct RigArgs {
1433    /// Rig to operate on (default: IGNITION_RIG, then [rig].default,
1434    /// then the cwd/convention scan)
1435    #[arg(long, value_name = "NAME")]
1436    pub rig: Option<String>,
1437
1438    #[command(subcommand)]
1439    pub command: RigCommand,
1440}
1441
1442#[derive(Debug, Subcommand)]
1443pub enum RigCommand {
1444    /// Bring the rig up (compose up -d --wait) and wait for the
1445    /// gateway: RUNNING, or uncommissioned-as-data (exit 0 + wizard
1446    /// hint in warnings)
1447    Up {
1448        /// Wait budget in seconds — BOTH compose's --wait-timeout and
1449        /// the commissioned probe deadline (default 300)
1450        #[arg(long, default_value_t = 300, value_name = "SECS")]
1451        timeout: u64,
1452
1453        /// Provision a third-party module for THIS invocation only
1454        /// (repeatable): `ID@VERSION`, e.g. `git@2.3.4`. ADDITIVE to
1455        /// any `[rigs.NAME.modules.*]` declared in config — a flag
1456        /// naming an id config already declares OVERRIDES that id's
1457        /// version for this run; there is NO subtractive form (a
1458        /// config-declared module cannot be omitted via this flag —
1459        /// remove it from config instead)
1460        #[arg(long, value_name = "ID@VERSION")]
1461        with_module: Vec<String>,
1462
1463        /// Re-verify every already-cached module version against its
1464        /// release feed before trusting it: an unchanged digest reuses
1465        /// the cache, a changed one REFUSES (exit 6) naming both
1466        /// digests — never silently accepted. Mutually exclusive with
1467        /// --accept-upstream-change (never implied by it)
1468        #[arg(long, conflicts_with = "accept_upstream_change")]
1469        refresh: bool,
1470
1471        /// Accept a changed digest the feed now reports for an
1472        /// already-cached module version — the deliberate, explicit
1473        /// way to accept a re-released artifact (downloads and
1474        /// verifies the NEW bytes, cached alongside the old entry,
1475        /// never over it). Mutually exclusive with --refresh
1476        #[arg(long, conflicts_with = "refresh")]
1477        accept_upstream_change: bool,
1478    },
1479    /// Stop the rig (compose down --remove-orphans; volumes KEPT —
1480    /// `reset` owns the teardown half)
1481    Down,
1482    /// Tear the rig down AND remove its volumes (down -v
1483    /// --remove-orphans), then bring it back up fresh — destructive,
1484    /// refused without --yes; no stale project/trial state survives
1485    Reset {
1486        /// Wait budget in seconds — BOTH compose's --wait-timeout and
1487        /// the commissioned probe deadline (default 300)
1488        #[arg(long, default_value_t = 300, value_name = "SECS")]
1489        timeout: u64,
1490
1491        /// Provision a third-party module for THIS invocation only
1492        /// (repeatable): `ID@VERSION`, e.g. `git@2.3.4`. Same
1493        /// additive/flag-wins/no-subtractive-form contract as `rig up
1494        /// --with-module` (`reset` runs the same up-half internally)
1495        #[arg(long, value_name = "ID@VERSION")]
1496        with_module: Vec<String>,
1497
1498        /// Re-verify every already-cached module version against its
1499        /// release feed before trusting it (see `rig up --refresh`).
1500        /// Mutually exclusive with --accept-upstream-change
1501        #[arg(long, conflicts_with = "accept_upstream_change")]
1502        refresh: bool,
1503
1504        /// Accept a changed digest the feed now reports for an
1505        /// already-cached module version (see `rig up
1506        /// --accept-upstream-change`). Mutually exclusive with --refresh
1507        #[arg(long, conflicts_with = "refresh")]
1508        accept_upstream_change: bool,
1509    },
1510    /// Structured status: services, ports, volumes (allowlist JSON;
1511    /// a down rig is exit-0 data)
1512    Status,
1513    /// Stream the rig's container logs (compose logs passthrough —
1514    /// raw lines, no envelope in any mode; the third streaming
1515    /// exception, README-documented)
1516    Logs {
1517        /// Lines to show from the end of each service's logs
1518        #[arg(long, default_value_t = 200, value_name = "N")]
1519        tail: u32,
1520        /// Follow: stream new lines as they occur (Ctrl-C stops —
1521        /// default process kill, no envelope)
1522        #[arg(short = 'f', long)]
1523        follow: bool,
1524        /// One service's logs only (see `ign rig status` for names)
1525        service: Option<String>,
1526    },
1527    /// Trial-license state: status is credential-free truth; reset
1528    /// (guarded) restarts an EXPIRED trial via the mechanism ladder
1529    /// (token-auth POST, else native gateway login)
1530    Trial(TrialArgs),
1531    /// Third-party module lifecycle beyond declaration: `uninstall`
1532    /// removes a module from the gateway entirely — guarded, and NOT
1533    /// undone by re-declaring it (Phase 19)
1534    Module(ModuleArgs),
1535    /// Snapshot the rig's gateway: native gwbk (roaming backup,
1536    /// streamed) + per-project exports + manifest.json, composed in a
1537    /// timestamped directory — repeatable state
1538    Snapshot {
1539        /// Output directory (default:
1540        /// ./ign-rig-snapshots/<rig>-<yyyyMMdd-HHmmss>/)
1541        #[arg(short = 'o', long, value_name = "DIR")]
1542        output: Option<PathBuf>,
1543    },
1544    /// Restore a gwbk onto the rig's gateway — destructive, refused
1545    /// without --yes; synchronous restore + restart, then a witnessed
1546    /// RUNNING wait
1547    Restore {
1548        /// The gwbk file to restore (from `ign rig snapshot`)
1549        #[arg(long, value_name = "PATH")]
1550        file: PathBuf,
1551        /// Post-restore RUNNING wait budget in seconds (floored at
1552        /// 300 — the gateway restarts after a restore)
1553        #[arg(long, default_value_t = 300, value_name = "SECS")]
1554        timeout: u64,
1555    },
1556}
1557
1558/// Trial subcommands (04-03, RIG-02/03). `status` reads the
1559/// unauthenticated trial endpoint + banners cross-check. `reset` is
1560/// the family's destructive verb (`--yes`-guarded, exit 2 without —
1561/// the reset precedent); the password NEVER rides a flag (env/secret
1562/// only — redaction discipline).
1563#[derive(Debug, clap::Args)]
1564pub struct TrialArgs {
1565    #[command(subcommand)]
1566    pub command: TrialCommand,
1567}
1568
1569#[derive(Debug, Clone, Subcommand)]
1570pub enum TrialCommand {
1571    /// Show the trial state: licenseMode, trialState, seconds left,
1572    /// expired — plus the banners cross-check. No credential needed
1573    /// (the endpoints answer unauthenticated — fresh-rig friendly)
1574    Status,
1575    /// Reset an EXPIRED trial to a fresh window — destructive,
1576    /// refused without --yes. Mechanism ladder: API-token POST
1577    /// (IGNITION_TOKEN) → native gateway login (--user /
1578    /// IGNITION_USER + IGNITION_PASSWORD). Non-expired trials refuse
1579    /// (trial_not_expired)
1580    Reset {
1581        /// Gateway admin username for the login rung (password comes
1582        /// from IGNITION_PASSWORD — never a flag)
1583        #[arg(long, value_name = "NAME")]
1584        user: Option<String>,
1585    },
1586}
1587
1588/// Module subcommands (Phase 19) — `uninstall` is the family's ONE
1589/// destructive verb: guarded (`--yes`-refused, the `rig reset`
1590/// precedent), and, unlike every other guarded verb in this codebase,
1591/// NOT undone by re-declaring the module afterward (recovery today
1592/// means `ign rig reset`, which destroys the rig's data volume).
1593#[derive(Debug, clap::Args)]
1594pub struct ModuleArgs {
1595    #[command(subcommand)]
1596    pub command: ModuleCommand,
1597}
1598
1599#[derive(Debug, Clone, Subcommand)]
1600pub enum ModuleCommand {
1601    /// Remove a module from the gateway entirely — destructive, refused
1602    /// without `--yes`. `ID` is a REGISTRY id (the same one `rig up
1603    /// --with-module` takes; see `ign rig up --with-module`), never a
1604    /// raw gateway module id — a hand-installed module cannot be named
1605    /// here. Needs `IGNITION_TOKEN`. The gateway refuses while the
1606    /// `.modl` is still mounted: undeclare the module and run `ign rig
1607    /// up` first. A successful uninstall is NOT reversible by
1608    /// re-declaring — recovery today means `ign rig reset --yes`,
1609    /// which destroys the rig's data volume.
1610    Uninstall {
1611        /// The registry id to uninstall (e.g. `git`).
1612        id: String,
1613    },
1614}
1615
1616/// Backup subcommands (07-02, BKUP-01) — the standalone surface of
1617/// the Phase 4 gwbk wire. `restore` is the family's destructive verb
1618/// (the 8th `--yes`-guarded set member: it REPLACES this gateway's
1619/// state); `download` is a streamed read, unguarded.
1620#[derive(Debug, clap::Args)]
1621pub struct BackupArgs {
1622    #[command(subcommand)]
1623    pub command: BackupCommand,
1624}
1625
1626#[derive(Debug, Subcommand)]
1627pub enum BackupCommand {
1628    /// Download a gwbk backup (streamed to disk)
1629    Download {
1630        /// Output file (default: the gateway's Content-Disposition
1631        /// name, else <profile>-backup.gwbk)
1632        #[arg(short, long, value_name = "FILE")]
1633        output: Option<PathBuf>,
1634        /// Backup type: roaming = portable across gateways (default);
1635        /// all includes gateway-specific state
1636        #[arg(long, value_enum, default_value_t = CliBackupType::Roaming)]
1637        r#type: CliBackupType,
1638    },
1639    /// Restore a gwbk onto THIS gateway — destructive, refused
1640    /// without --yes; the gateway restarts and blocks for minutes
1641    /// after the restore
1642    Restore {
1643        /// The gwbk file to restore (from `ign backup download` or
1644        /// `ign rig snapshot`)
1645        file: PathBuf,
1646    },
1647}
1648
1649/// CLI value-enum mirror of the core `BackupType` (ignition-core
1650/// stays clap-free; `From` converts at the dispatch seam).
1651#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
1652pub enum CliBackupType {
1653    /// Portable backup (cross-gateway)
1654    Roaming,
1655    /// Includes gateway-specific state
1656    All,
1657}
1658
1659impl From<CliBackupType> for ignition_core::client::backup::BackupType {
1660    fn from(value: CliBackupType) -> Self {
1661        match value {
1662            CliBackupType::Roaming => Self::Roaming,
1663            CliBackupType::All => Self::All,
1664        }
1665    }
1666}
1667
1668/// EAM subcommands (07-02, BKUP-02) — the read-heavy surface with
1669/// guarded writes. Every runtime verb honestly reports the
1670/// controller-mode state gate (`eam_not_controller` on a stock
1671/// gateway — the README documents the manual flip).
1672#[derive(Debug, clap::Args)]
1673pub struct EamArgs {
1674    #[command(subcommand)]
1675    pub command: EamCommand,
1676}
1677
1678#[derive(Debug, Subcommand)]
1679pub enum EamCommand {
1680    /// EAM task run history (the gateway's own newest-first order)
1681    History {
1682        /// Max entries (default 200 — ALWAYS sent explicitly; the
1683        /// server default is unlimited)
1684        #[arg(long, value_name = "N")]
1685        limit: Option<u32>,
1686        /// Substring search over task names
1687        #[arg(long, value_name = "TEXT")]
1688        search: Option<String>,
1689    },
1690    /// Task definitions: bare `ign eam tasks` lists; with a name
1691    /// shows one definition + its scheduled state
1692    Tasks {
1693        /// One task definition's detail (omit to list all)
1694        name: Option<String>,
1695    },
1696    /// Task definition writes (create; force-dispatch) — `eam task`
1697    /// requires a subcommand (the `rig trial` shape)
1698    #[command(subcommand)]
1699    Task(EamTaskCommand),
1700}
1701
1702/// `eam task …` — the guarded writes (07-02 Task 3; the five
1703/// lifecycle/mutation verbs arrive 10-04). `new` carries the typed
1704/// guard ladder (backup+OnDemand unguarded; mutating types and any
1705/// non-OnDemand schedule need --yes; restore/install/upgrade types
1706/// refuse outright — see README); `force` dispatches NOW, always
1707/// guarded; `suspend`/`resume`/`cancel`/`modify`/`delete` are the
1708/// two-tier guarded verbs (pure precheck → blast-radius preview
1709/// fetch → confirm-with-preview → action — the refusal message IS
1710/// the blast radius).
1711#[derive(Debug, Subcommand)]
1712pub enum EamTaskCommand {
1713    /// Create a task definition (scheduleMode defaults to OnDemand —
1714    /// never auto-fires)
1715    New {
1716        /// Task definition name (any resource name — e.g. nightly-backup)
1717        name: String,
1718        /// Task type — the openapi taxonomy, three classes with different guard consequences:
1719        ///
1720        /// - benign (no --yes with the default OnDemand schedule): eam_backup
1721        ///
1722        /// - mutating (need --yes — they act on their agent targets when dispatched):
1723        ///   eam_restart, eam_sendProject, eam_sendResource, eam_sendTags,
1724        ///   eam_activateLicense, eam_updateLicense, eam_unactivateLicense
1725        ///
1726        /// - refused (exit 6 eam_task_type_refused — fleet-destructive; run from the EAM console):
1727        ///   eam_restoreBackup, eam_installModules, eam_remoteUpgrade
1728        ///
1729        /// Any OTHER type fails safe to the --yes rung (unknown future
1730        /// types stay accepted, never silently unguarded).
1731        ///
1732        /// Example: ign eam task new nightly-backup eam_backup --target gw-a
1733        r#type: String,
1734        /// Target gateway name (repeatable; the GNET agent id)
1735        #[arg(long, value_name = "NAME")]
1736        target: Vec<String>,
1737        /// Setting as K=V with scalar auto-typing — bool/int ride
1738        /// typed, anything else stays a string (repeatable;
1739        /// arrays/objects need --definition)
1740        #[arg(long, value_name = "K=V")]
1741        setting: Vec<String>,
1742        /// Full-JSON settings file deep-merged over the composed
1743        /// `config.settings` (the typed/array settings path)
1744        #[arg(long, value_name = "PATH", conflicts_with = "setting")]
1745        definition: Option<PathBuf>,
1746        /// Schedule mode (default OnDemand — never auto-fires;
1747        /// Immediate/Scheduled/AtTime/AtDelay require --yes)
1748        #[arg(long, value_enum, default_value_t = ScheduleMode::OnDemand)]
1749        schedule_mode: ScheduleMode,
1750    },
1751    /// Force-dispatch a task NOW — destructive, refused without
1752    /// --yes (it dispatches to the agent targets immediately). The
1753    /// confirmation prompt (and every refusal) carries the
1754    /// blast-radius preview line.
1755    Force {
1756        /// Task definition name to dispatch
1757        name: String,
1758    },
1759    /// Suspend a task definition's scheduled dispatches — TASK-scoped
1760    /// runtime verb (there is NO agent-level suspend on the wire; an
1761    /// "agent" is suspended by suspending its tasks). Refused exit 2
1762    /// without --yes, the blast-radius preview in the refusal
1763    /// message; on a stock gateway the runtime seam honestly refuses
1764    /// `eam_not_controller` (exit 6).
1765    Suspend {
1766        /// Task definition name to suspend
1767        name: String,
1768    },
1769    /// Resume a suspended task definition — the suspend inverse
1770    /// (TASK-scoped, like suspend). Refused exit 2 without --yes
1771    /// with the blast-radius preview in the message.
1772    Resume {
1773        /// Task definition name to resume
1774        name: String,
1775    },
1776    /// Cancel a task's PENDING execution (TASK-scoped). Refused exit
1777    /// 2 without --yes with the blast-radius preview in the message;
1778    /// nothing pending is an honest no-op (`fired: false`), a
1779    /// `canCancel: false` row reports the gateway's own refusal.
1780    Cancel {
1781        /// Task definition whose pending execution to cancel
1782        name: String,
1783    },
1784    /// Rewrite targeted keys of a task definition — the full-record
1785    /// read-modify-write (every key the gateway answered rides back;
1786    /// only the targeted keys change). Rename is deliberately
1787    /// ABSENT (the wire answers PUT-rename with 404 — see README's
1788    /// create-new + delete-old composite). Refused exit 2 without
1789    /// --yes with the blast-radius preview in the message.
1790    ///
1791    /// Example: ign eam task modify nightly-backup --enable
1792    Modify {
1793        /// Task definition to modify
1794        name: String,
1795        /// Flip the definition's `enabled` key to true
1796        #[arg(long)]
1797        enable: bool,
1798        /// Flip the definition's `enabled` key to false
1799        /// (conflicts with --enable)
1800        #[arg(long, conflicts_with = "enable")]
1801        disable: bool,
1802        /// Rewrite `config.profile.scheduleMode` (e.g. OnDemand,
1803        /// Scheduled — the scheduleDetails string itself is out of
1804        /// scope; the read-back shows the landed pairing)
1805        #[arg(long, value_name = "MODE")]
1806        schedule_mode: Option<String>,
1807        /// Deep-merge a settings entry over the found
1808        /// `config.settings` (K=V with scalar auto-typing;
1809        /// repeatable — objects merge recursively, arrays/scalars
1810        /// replace)
1811        #[arg(long, value_name = "K=V")]
1812        setting: Vec<String>,
1813        /// Rewrite the definition's `description`
1814        #[arg(long, value_name = "TEXT")]
1815        description: Option<String>,
1816    },
1817    /// Delete a task definition — signature-keyed (the gateway
1818    /// refuses a stale signature), behind the same --yes guard.
1819    /// Refused exit 2 without --yes with the blast-radius preview
1820    /// in the message.
1821    Delete {
1822        /// Task definition to delete
1823        name: String,
1824    },
1825}
1826
1827/// Schedule modes (the openapi taxonomy's user-facing subset —
1828/// SuspendedByFailover is system-owned, not a CLI value).
1829#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
1830pub enum ScheduleMode {
1831    /// Never fires on its own (force dispatches it)
1832    OnDemand,
1833    /// Fires immediately on create (requires --yes)
1834    Immediate,
1835    /// Fires on a schedule (requires --yes)
1836    Scheduled,
1837    /// Fires at an absolute time (requires --yes)
1838    AtTime,
1839    /// Fires after a delay (requires --yes)
1840    AtDelay,
1841}
1842
1843impl ScheduleMode {
1844    /// The wire token.
1845    pub fn wire(self) -> &'static str {
1846        match self {
1847            Self::OnDemand => "OnDemand",
1848            Self::Immediate => "Immediate",
1849            Self::Scheduled => "Scheduled",
1850            Self::AtTime => "AtTime",
1851            Self::AtDelay => "AtDelay",
1852        }
1853    }
1854}
1855
1856/// Script subcommands (07-03, SCRPT-01) — the smallest family: ONE
1857/// verb over the already-shipped, already-secured scriptExec route.
1858/// `script` requires a subcommand (the `rig trial` shape — no bare
1859/// row); the opt-in is STRUCTURAL (the route deploys only via
1860/// `ign webdev deploy --with-script-exec`, which persists the
1861/// secret at 0600), so `run` carries NO `--yes` guard by design —
1862/// the deploy flag IS the opt-in and agents need the verb
1863/// non-interactive (the research-adopted decision).
1864#[derive(Debug, clap::Args)]
1865pub struct ScriptArgs {
1866    #[command(subcommand)]
1867    pub command: ScriptCommand,
1868}
1869
1870/// Testing subcommands (QUICK-p0g) — the standalone wrapper over the
1871/// gateway testing bundle's `testing/run` route. `testing` requires a
1872/// subcommand (the `script` shape — no bare row); there is no `--yes`
1873/// guard: running a test suite is a read-shaped operation from the
1874/// CLI's side and agents need the verb non-interactive.
1875#[derive(Debug, clap::Args)]
1876pub struct TestingArgs {
1877    #[command(subcommand)]
1878    pub command: TestingCommand,
1879}
1880
1881/// `ign e2e` args (QUICK-tg4) — the browser-E2E pair.
1882#[derive(Debug, clap::Args)]
1883pub struct E2eArgs {
1884    #[command(subcommand)]
1885    pub command: E2eCmd,
1886}
1887
1888#[derive(Debug, Subcommand)]
1889pub enum E2eCmd {
1890    /// Diagnose the browser-E2E setup: node (≥20), npm,
1891    /// @playwright/test in the scaffold, a downloaded chromium, the
1892    /// gateway's testing bundle, and the gateway's trial state.
1893    /// Read-only and offline-safe — **exits 0 whenever the diagnosis
1894    /// completes**, so every finding is a `checks[]` row rather than
1895    /// an exit code. The two gateway rows report `skip` when no
1896    /// profile resolves or no --project is given
1897    Doctor {
1898        /// The scaffold directory to inspect (default: ./e2e)
1899        #[arg(value_name = "DIR")]
1900        dir: Option<PathBuf>,
1901        /// The gateway project whose testing bundle is probed. No
1902        /// default — without it the bundle row honestly skips
1903        #[arg(long, value_name = "NAME")]
1904        project: Option<String>,
1905    },
1906
1907    /// Scaffold a Playwright E2E suite into DIR (default ./e2e): a
1908    /// runner config, a global setup that logs in through `ign
1909    /// session login` and gates on `ign testing run`, gateway tag/
1910    /// script helpers, an example suite, a .gitignore, and a README —
1911    /// then `npm install`. **Idempotent**: an existing file is
1912    /// reported `skipped` and never overwritten. Requires the global
1913    /// --yes; without it the verb writes nothing, spawns nothing, and
1914    /// refuses exit 2 after previewing every file and both commands
1915    Init {
1916        /// Where the scaffold lands (default: ./e2e)
1917        #[arg(value_name = "DIR")]
1918        dir: Option<PathBuf>,
1919        /// The project whose WebDev testing routes the helpers call
1920        /// (default: ign-cli)
1921        #[arg(long, value_name = "NAME")]
1922        project: Option<String>,
1923        /// The Perspective project the browser test navigates to
1924        /// (default: the effective --project)
1925        #[arg(long, value_name = "NAME")]
1926        run_project: Option<String>,
1927        /// Also download the Chromium build after installing
1928        #[arg(long)]
1929        browsers: bool,
1930    },
1931}
1932
1933/// `ign session` args (QUICK-tg4) — the SINGULAR verb family: one
1934/// gateway login session for this CLI to hand to a browser harness.
1935///
1936/// The plural `ign sessions` family (`SessionsArgs` / `SessionsCommand`)
1937/// is a different thing entirely: it LISTS the gateway's connected
1938/// designer/Perspective/Vision sessions. The names are deliberately
1939/// kept apart here so a future reader never merges them.
1940#[derive(Debug, clap::Args)]
1941pub struct SessionArgs {
1942    #[command(subcommand)]
1943    pub command: SessionCmd,
1944}
1945
1946#[derive(Debug, Subcommand)]
1947pub enum SessionCmd {
1948    /// Log in to the gateway and return the live session: the
1949    /// `webui-sid-*` cookie name and value, the CSRF token for
1950    /// `X-CSRF-Token`, the gateway URL, and `storage_state` — a
1951    /// Playwright `storageState` document ready to write straight to
1952    /// disk and point `use.storageState` at. The password is env-only
1953    /// (`IGNITION_PASSWORD`; missing exits 3, rejected exits 5). The
1954    /// cookie value and CSRF token are exposed ONLY under the global
1955    /// --json flag
1956    Login {
1957        /// Gateway login user (default: $IGNITION_USER, else admin)
1958        #[arg(long, value_name = "NAME")]
1959        user: Option<String>,
1960    },
1961}
1962
1963/// The CLI-side `--format` enum (QUICK-p0g) — the clap `ValueEnum`
1964/// mirror of core's plain `TestingFormat`, the `TransferFormat`
1965/// precedent (core keeps framework-free enums; the `From` impl lives
1966/// beside this one). `get_possible_values()` is what feeds the MCP
1967/// tool schema a real enum instead of a free string.
1968///
1969/// EVERY value produces the SAME verdict and exit code: the route is
1970/// always asked for json and the junit/text reports are rendered
1971/// client-side, because the route's own junit/text answers stay HTTP
1972/// 200 and carry no counts — a passthrough would exit 0 on a red
1973/// suite.
1974#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
1975pub enum TestingFormatArg {
1976    /// Structured results only (default) — `data.report` stays empty
1977    #[default]
1978    Json,
1979    /// JUnit XML in `data.report`, for a CI test reporter
1980    Junit,
1981    /// Console-style text in `data.report`, for a human reading logs
1982    Text,
1983}
1984
1985impl From<TestingFormatArg> for ignition_core::actions::testing::TestingFormat {
1986    fn from(value: TestingFormatArg) -> Self {
1987        match value {
1988            TestingFormatArg::Json => Self::Json,
1989            TestingFormatArg::Junit => Self::Junit,
1990            TestingFormatArg::Text => Self::Text,
1991        }
1992    }
1993}
1994
1995#[derive(Debug, Subcommand)]
1996pub enum TestingCommand {
1997    /// Run the gateway-side Jython test suite and return the machine
1998    /// verdict (exit 0 green, exit 6 red with the FULL results still
1999    /// in the envelope)
2000    Run {
2001        /// The project whose testing bundle runs. REQUIRED — the
2002        /// bundle deploys per project and running a suite executes
2003        /// that project's gateway-side code, so there is
2004        /// deliberately no `ign-cli` default here
2005        #[arg(long, value_name = "NAME", required = true)]
2006        project: String,
2007        /// List the gateway's discovered test modules instead of
2008        /// running them
2009        #[arg(long, conflicts_with_all = ["module", "package"])]
2010        discover: bool,
2011        /// Run ONE dotted module (`proj.foo_test`); mutually
2012        /// exclusive with --package and --discover
2013        #[arg(long, value_name = "DOTTED", conflicts_with = "package")]
2014        module: Option<String>,
2015        /// Run every module under a package prefix (`proj.`);
2016        /// mutually exclusive with --module and --discover
2017        #[arg(long, value_name = "PREFIX")]
2018        package: Option<String>,
2019        /// Report rendered into `data.report`. The verdict and exit
2020        /// code are IDENTICAL in every format — the structured
2021        /// results always ride under `data.results`
2022        #[arg(long, value_name = "FORMAT", default_value = "json")]
2023        format: TestingFormatArg,
2024    },
2025}
2026
2027/// `ign lint` args (07-04, INTR-02) — the ignition-lint delegation:
2028/// PATHS map to `--target <path>` pairs on the child's arg vector;
2029/// `--` passthrough args ride verbatim after them (power users:
2030/// `--profile`, `--checks`, `--fail-on`, …).
2031#[derive(Debug, clap::Args)]
2032pub struct LintArgs {
2033    /// Paths to lint (one --target pair each)
2034    #[arg(value_name = "PATH", required = true)]
2035    pub paths: Vec<String>,
2036    /// Exit with the linter's own exit code (CI mode). Default is
2037    /// the doctor posture: findings + child_exit_code + the parsed
2038    /// report ride as data and the command exits 0 whenever the
2039    /// tool RAN
2040    #[arg(long)]
2041    pub strict: bool,
2042    /// Extra args passed to ignition-lint verbatim (after --)
2043    #[arg(last = true, value_name = "ARGS")]
2044    pub passthrough: Vec<String>,
2045}
2046
2047#[derive(Debug, Subcommand)]
2048pub enum ScriptCommand {
2049    /// Execute gateway-side Python (Jython) — non-interactive, the
2050    /// route's entire purpose
2051    Run {
2052        /// Inline Python source (a one-liner's best form)
2053        #[arg(long, value_name = "PY")]
2054        code: Option<String>,
2055        /// Python source file (`-` reads stdin — the agent pipe
2056        /// path); giving both --code and --file refuses
2057        /// `invalid_input` (exit 2) pre-resolution
2058        #[arg(long, value_name = "PATH")]
2059        file: Option<String>,
2060        /// The deployed routes' project (default ign-cli — where
2061        /// `ign webdev deploy` put scriptExec)
2062        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
2063        project: String,
2064    },
2065}
2066
2067/// `ign api` args (09-03, EXT-01) — the raw-passthrough family. One
2068/// verb today (`call`); the escape hatch for the 80+ gateway endpoint
2069/// families no curated command covers.
2070#[derive(Debug, clap::Args)]
2071pub struct ApiArgs {
2072    #[command(subcommand)]
2073    pub command: ApiCommand,
2074}
2075
2076#[derive(Debug, Subcommand)]
2077pub enum ApiCommand {
2078    /// Call any gateway REST endpoint raw: `--method`/`--path` are the
2079    /// request, `--data`/`--header`/`--query` shape it, and the
2080    /// envelope's `data.result.data` is the gateway's JSON VERBATIM
2081    /// (no field dropped, no value coerced, key order preserved — the
2082    /// README's documented contract exception). Auth-pattern headers
2083    /// (`Authorization`, `X-Ignition-API-Token`, `Cookie`) are
2084    /// refused pre-I/O — credentials come from the profile.
2085    Call(ApiCallArgs),
2086}
2087
2088/// `ign api call` args (09-03, EXT-01). Arbitrary verbs accepted —
2089/// reqwest's `Method` parser validates (usage-class refusal for
2090/// garbage); `--data` is RAW TEXT on ANY method (GET/DELETE bodies
2091/// allowed — curl parity; the gateway's answer classifies); ONE query
2092/// mechanism (repeatable `--query k=v`, never `?` in `--path`).
2093#[derive(Debug, clap::Args)]
2094pub struct ApiCallArgs {
2095    /// HTTP method (GET, POST, PUT, DELETE, PATCH, HEAD, …)
2096    #[arg(long, value_name = "METHOD")]
2097    pub method: String,
2098    /// Absolute path on the gateway (must start with `/`; no `?`, no host)
2099    #[arg(long, value_name = "PATH")]
2100    pub path: String,
2101    /// Raw body text passthrough (rides ANY method — curl parity)
2102    #[arg(long, value_name = "TEXT")]
2103    pub data: Option<String>,
2104    /// Extra request header `Name: Value` (repeatable; split on the
2105    /// FIRST `:`; auth-pattern names are refused pre-I/O)
2106    #[arg(long, value_name = "NAME: VALUE")]
2107    pub header: Vec<String>,
2108    /// Query pair `k=v` (repeatable; split on the FIRST `=` — the
2109    /// ONE query mechanism)
2110    #[arg(long, value_name = "K=V")]
2111    pub query: Vec<String>,
2112}
2113
2114/// `ign license` args (09-04, EXT-02) — the license morning-check
2115/// family. One verb today; room to grow (the uniform-leaf shape).
2116#[derive(Debug, clap::Args)]
2117pub struct LicenseArgs {
2118    #[command(subcommand)]
2119    pub command: LicenseCommand,
2120}
2121
2122#[derive(Debug, Subcommand)]
2123pub enum LicenseCommand {
2124    /// License inventory + trial state in ONE command: the
2125    /// hardware-key item rows and effective stamp ride the
2126    /// `/licenses` read, the license mode + countdown ride the trial
2127    /// companion (the mode is NOT on the licenses payload — capture
2128    /// fact)
2129    Status,
2130}
2131
2132/// `ign redundancy` args (09-04, EXT-02) — the redundancy
2133/// morning-check family.
2134#[derive(Debug, clap::Args)]
2135pub struct RedundancyArgs {
2136    #[command(subcommand)]
2137    pub command: RedundancyCommand,
2138}
2139
2140#[derive(Debug, Subcommand)]
2141pub enum RedundancyCommand {
2142    /// The flat redundancy status: role, project state, peer
2143    /// connection, config access, sync/failover pending, uptime
2144    /// (ms since gateway start — capture-proven) and last-sync (the
2145    /// `-1` never-synced sentinel on fresh rigs)
2146    Status {
2147        /// Also read a second node through PROFILE and name the active
2148        /// node (activity level decides, never role)
2149        #[arg(long, value_name = "PROFILE")]
2150        peer: Option<String>,
2151    },
2152}
2153
2154/// `ign gan` args (09-04, EXT-02) — the Gateway Area Network
2155/// morning-check family.
2156#[derive(Debug, clap::Args)]
2157pub struct GanArgs {
2158    #[command(subcommand)]
2159    pub command: GanCommand,
2160}
2161
2162#[derive(Debug, Subcommand)]
2163pub enum GanCommand {
2164    /// The GAN overview: total/running connections, in/out byte
2165    /// rates, remote gateways — all zeros are healthy data on a
2166    /// non-GAN gateway (the capture IS the canonical shape)
2167    Status,
2168}
2169
2170/// `ign diagnostics` args (09-05, EXT-02) — the support-bundle
2171/// family: `bundle` nests one level (the `tags provider` grouped-
2172/// subfamily pattern).
2173#[derive(Debug, clap::Args)]
2174pub struct DiagnosticsArgs {
2175    #[command(subcommand)]
2176    pub command: DiagnosticsCommand,
2177}
2178
2179#[derive(Debug, Subcommand)]
2180pub enum DiagnosticsCommand {
2181    /// Support-bundle operations (generate / status / download /
2182    /// wait) — the state machine is ENCODED FROM LIVE CAPTURES:
2183    /// observed states are exactly `Generating` (mid-generation) and
2184    /// `Valid` (terminal/ready), PascalCase, both rigs
2185    #[command(subcommand)]
2186    Bundle(BundleCommand),
2187}
2188
2189/// The four bundle verbs (09-05). Nothing here is destructive:
2190/// generate creates a support bundle, download writes a local file,
2191/// wait polls — none carry `--yes` (the backup-download posture).
2192#[derive(Debug, Subcommand)]
2193pub enum BundleCommand {
2194    /// Start bundle generation (POST, no body) — the 200 answer IS
2195    /// the fresh status (live capture: `{"state":"Generating"}`);
2196    /// generation takes ~2–6 s on a fresh rig, so pair with `wait`
2197    Generate,
2198    /// The bundle status: `state` rides the CAPTURED vocabulary
2199    /// (`Generating` / `Valid` — unknown future states pass through
2200    /// verbatim, never refused); `fileSize` (bytes) appears only
2201    /// when `Valid`
2202    Status,
2203    /// Download the bundle ZIP (streamed to disk; the request rides
2204    /// a 300 s per-request timeout — the 30 s client default would
2205    /// truncate MB-sized bundles)
2206    Download {
2207        /// Output file (default: the gateway's Content-Disposition
2208        /// name, else ignition-diagnostics-bundle-<unix_ts>.zip)
2209        #[arg(short, long, value_name = "FILE")]
2210        output: Option<PathBuf>,
2211    },
2212    /// Poll the status until a terminal captured state (`Valid`)
2213    /// — an UNKNOWN state (outside the captured vocabulary) keeps
2214    /// polling honestly until the deadline; deadline expiry is
2215    /// exit 4 `network_error` (the restart-wait convention, no new
2216    /// slug) with the last observed state in the message
2217    Wait {
2218        /// Poll interval in seconds
2219        #[arg(long, default_value_t = 2, value_name = "SECS")]
2220        interval: u64,
2221        /// Give up after this many seconds (default 300 — mirrors
2222        /// restart --wait)
2223        #[arg(long, default_value_t = 300, value_name = "SECS")]
2224        timeout: u64,
2225    },
2226}
2227
2228/// `ign jvm` args — the SPECIAL-MODE profiling family. Every leaf
2229/// carries `--project` (the deployed scriptExec route's home, the
2230/// `script run` default) and the intrusive leaves carry the family's
2231/// own gate flag: `--i-accept-the-risk` is deliberately NOT the
2232/// global `--yes` — a generic "confirm destructive ops" blanket must
2233/// never silently cover ACTIVE JVM instrumentation.
2234#[derive(Debug, clap::Args)]
2235pub struct JvmArgs {
2236    #[command(subcommand)]
2237    pub command: JvmCommand,
2238}
2239
2240#[derive(Debug, Subcommand)]
2241pub enum JvmCommand {
2242    /// Sample every JVM thread's stack for a capped window and rank
2243    /// the hottest methods and stacks (the folded output is
2244    /// flamegraph-ready). ACTIVE instrumentation: CPU overhead grows
2245    /// with thread count x frequency — refused without
2246    /// --i-accept-the-risk; duration is HARD-CAPPED at 120s
2247    Sample {
2248        /// Seconds to sample per window (default 30 — 10 with
2249        /// --follow; 5–120, the cap is not configurable)
2250        #[arg(long, value_name = "SECS")]
2251        duration_secs: Option<u64>,
2252        /// Milliseconds between thread dumps (default 20 — 50 with
2253        /// --follow; 5–1000 — below 5ms the sampler thread itself
2254        /// skews the JVM)
2255        #[arg(long, value_name = "MS")]
2256        interval_ms: Option<u64>,
2257        /// Hot-method rows to report (default 25; cap 100)
2258        #[arg(long, default_value_t = 25, value_name = "N")]
2259        top: u64,
2260        /// Also write the folded stacks to this file (flamegraph.pl /
2261        /// speedscope input; one-shot only — refused with --follow)
2262        #[arg(long, value_name = "PATH")]
2263        folded_out: Option<PathBuf>,
2264        /// CONTINUOUS MODE: repeat bounded sampling windows
2265        /// back-to-back, streaming each window's rollup as it lands
2266        /// (NDJSON envelopes under --json/--compact, the `logs -f`
2267        /// precedent), until Ctrl-C. Continuous sampling IS
2268        /// continuous gateway overhead — watch it, then stop it
2269        #[arg(long, action = ArgAction::SetTrue)]
2270        follow: bool,
2271        /// REQUIRED acknowledgment that this actively instruments the
2272        /// gateway JVM and must not run on production casually —
2273        /// --yes does NOT satisfy this gate
2274        #[arg(long, action = ArgAction::SetTrue)]
2275        i_accept_the_risk: bool,
2276        /// The deployed routes' project (default ign-cli — where
2277        /// `ign webdev deploy` put scriptExec)
2278        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
2279        project: String,
2280    },
2281    /// Java Flight Recorder operations — `start` is gated active
2282    /// instrumentation; `status` and `stop` are ungated (checking and
2283    /// cleaning up must always be cheap to reach)
2284    #[command(arg_required_else_help = true)]
2285    Jfr(JvmJfrArgs),
2286}
2287
2288/// `ign jvm jfr` args (the `tags provider` grouped-subfamily pattern).
2289#[derive(Debug, clap::Args)]
2290pub struct JvmJfrArgs {
2291    #[command(subcommand)]
2292    pub command: JvmJfrCommand,
2293}
2294
2295#[derive(Debug, Subcommand)]
2296pub enum JvmJfrCommand {
2297    /// Start a flight recording named `ign-cli` — memory-buffered
2298    /// (nothing touches gateway disk until `stop` dumps) and
2299    /// duration-capped so it auto-stops even if you forget. Refused
2300    /// without --i-accept-the-risk; duration HARD-CAPPED at 300s
2301    Start {
2302        /// Recording duration in seconds — the auto-stop deadline
2303        /// (default 60; 10–300, the cap is not configurable)
2304        #[arg(long, default_value_t = 60, value_name = "SECS")]
2305        duration_secs: u64,
2306        /// The JDK's bundled settings: `default` (lower overhead) or
2307        /// `profile` (deeper, heavier)
2308        #[arg(long, value_enum, default_value_t = JfrSettingsArg::Default, value_name = "NAME")]
2309        settings: JfrSettingsArg,
2310        /// REQUIRED acknowledgment that this actively instruments the
2311        /// gateway JVM and must not run on production casually —
2312        /// --yes does NOT satisfy this gate
2313        #[arg(long, action = ArgAction::SetTrue)]
2314        i_accept_the_risk: bool,
2315        /// The deployed routes' project (default ign-cli)
2316        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
2317        project: String,
2318    },
2319    /// List the JVM's flight recordings verbatim (READ-only — not
2320    /// gated; this is how you confirm a recording is really gone)
2321    Status {
2322        /// The deployed routes' project (default ign-cli)
2323        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
2324        project: String,
2325    },
2326    /// Stop the `ign-cli` recording, dump it, fetch the .jfr, and
2327    /// delete the gateway-side file (cleanup — NOT gated: stopping
2328    /// reduces risk and must never be refused). Dumps over 32 MiB
2329    /// are left on the gateway with the remote path reported
2330    Stop {
2331        /// Local output file (default: ign-cli-<unix_ts>.jfr)
2332        #[arg(short, long, value_name = "FILE")]
2333        output: Option<PathBuf>,
2334        /// The deployed routes' project (default ign-cli)
2335        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
2336        project: String,
2337    },
2338}
2339
2340/// The JFR settings whitelist as a clap ValueEnum (free text never
2341/// reaches the MBean invocation).
2342#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
2343pub enum JfrSettingsArg {
2344    /// `settings=default` — the lower-overhead bundled config
2345    Default,
2346    /// `settings=profile` — the deeper, heavier bundled config
2347    Profile,
2348}
2349
2350/// Profile subcommands (nested: a struct wrapper carrying the subcommand
2351/// enum, so `Commands::Profile` gets an `Args` payload).
2352#[derive(Debug, clap::Args)]
2353pub struct ProfileArgs {
2354    #[command(subcommand)]
2355    pub command: ProfileCmd,
2356}
2357
2358/// Sessions args: the family filter rides the TOP level so bare
2359/// `ign sessions [--type X]` lists (must-have truth #1) while
2360/// `ign sessions terminate …` carries the destructive half.
2361#[derive(Debug, clap::Args)]
2362pub struct SessionsArgs {
2363    /// Filter to one session family (default: all three merged)
2364    #[arg(long, value_enum)]
2365    pub r#type: Option<SessionType>,
2366
2367    #[command(subcommand)]
2368    pub command: Option<SessionsCmd>,
2369}
2370
2371#[derive(Debug, Subcommand)]
2372pub enum SessionsCmd {
2373    /// Terminate (designer: prune / vision: close) a session —
2374    /// destructive, refused without --yes
2375    Terminate {
2376        /// Session family holding the id
2377        #[arg(long, value_enum)]
2378        r#type: SessionType,
2379        /// Session/client id to terminate (see `ign sessions`)
2380        #[arg(long)]
2381        id: String,
2382        /// Message shown to the session's user (Perspective only)
2383        #[arg(long)]
2384        message: Option<String>,
2385    },
2386}
2387
2388/// Logs args: the query filters ride the TOP level so bare
2389/// `ign logs [-f]` lists/tails (must-have truth #1) while `download`
2390/// and the `loggers` subtree hang off the optional subcommand — the
2391/// SessionsArgs precedent.
2392#[derive(Debug, clap::Args)]
2393pub struct LogsArgs {
2394    /// Only entries from this logger (name prefix)
2395    #[arg(long, value_name = "NAME")]
2396    pub logger: Option<String>,
2397    /// Minimum level to include (server-side filter)
2398    #[arg(long, value_enum, value_name = "LEVEL")]
2399    pub min_level: Option<LogLevel>,
2400    /// Start from an absolute EPOCH-MS or a relative span (500ms, 30s,
2401    /// 5min, 2h) — parsed to epoch-ms at arg-parse time
2402    #[arg(
2403        long,
2404        value_name = "EPOCH_MS|Nms|Ns|Nmin|Nh",
2405        value_parser = parse_since_arg
2406    )]
2407    pub since: Option<i64>,
2408    /// Max entries — the server default is UNLIMITED, never used here
2409    #[arg(long, default_value_t = 200)]
2410    pub limit: i64,
2411    /// Follow: stream new entries as they occur (poll-based)
2412    #[arg(short = 'f', long)]
2413    pub follow: bool,
2414    /// Poll interval in seconds (follow mode)
2415    #[arg(long, default_value_t = 2, value_name = "SECS")]
2416    pub interval: u64,
2417    /// Stop after this many seconds (follow mode; default: until Ctrl-C)
2418    #[arg(long, value_name = "SECS")]
2419    pub timeout: Option<u64>,
2420    #[command(subcommand)]
2421    pub command: Option<LogsCmd>,
2422}
2423
2424#[derive(Debug, Subcommand)]
2425pub enum LogsCmd {
2426    /// Download the log archive — a SQLite .idb, never a zip
2427    Download {
2428        /// Output file (default: the gateway's Content-Disposition
2429        /// name, else <profile>-logs-<ts>.idb)
2430        #[arg(short, long, value_name = "FILE")]
2431        output: Option<PathBuf>,
2432    },
2433    /// List loggers / manage logger levels
2434    Loggers(LoggersArgs),
2435}
2436
2437#[derive(Debug, clap::Args)]
2438pub struct LoggersArgs {
2439    /// Substring search over logger names
2440    #[arg(long, value_name = "TEXT")]
2441    pub search: Option<String>,
2442    #[command(subcommand)]
2443    pub command: Option<LoggersCmd>,
2444}
2445
2446#[derive(Debug, Subcommand)]
2447pub enum LoggersCmd {
2448    /// Set one logger's level — a mutation, refused without --yes
2449    Set {
2450        /// Logger name (see `ign logs loggers`)
2451        name: String,
2452        /// Level to set
2453        #[arg(value_enum)]
2454        level: LogLevel,
2455    },
2456    /// Reset ALL logger levels to defaults — refused without --yes
2457    Reset,
2458}
2459
2460/// The seven spec-documented log levels (TRACE..OFF), value-enum form
2461/// for clap; [`LogLevel::wire`] yields the uppercase wire token.
2462#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
2463pub enum LogLevel {
2464    Trace,
2465    Debug,
2466    Info,
2467    Warn,
2468    Error,
2469    Fatal,
2470    Off,
2471}
2472
2473impl LogLevel {
2474    /// The uppercase wire token the gateway expects.
2475    pub fn wire(self) -> &'static str {
2476        match self {
2477            Self::Trace => "TRACE",
2478            Self::Debug => "DEBUG",
2479            Self::Info => "INFO",
2480            Self::Warn => "WARN",
2481            Self::Error => "ERROR",
2482            Self::Fatal => "FATAL",
2483            Self::Off => "OFF",
2484        }
2485    }
2486}
2487
2488/// clap value parser delegating to the core `--since` grammar against
2489/// the current time (a relative span resolves at parse time) — invalid
2490/// specs are clap usage errors (exit 2) like any bad flag value.
2491fn parse_since_arg(spec: &str) -> Result<i64, String> {
2492    let now_ms = std::time::SystemTime::now()
2493        .duration_since(std::time::UNIX_EPOCH)
2494        .expect("system clock is after the unix epoch")
2495        .as_millis() as i64;
2496    ignition_core::actions::logs::parse_since(spec, now_ms)
2497}
2498
2499/// CLI value-enum mirrors of the core action enums (ignition-core stays
2500/// clap-free; `From` converts at the dispatch seam).
2501#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
2502pub enum SessionType {
2503    /// Designer sessions
2504    Designer,
2505    /// Perspective browser sessions
2506    Perspective,
2507    /// Vision clients
2508    Vision,
2509}
2510
2511impl From<SessionType> for ignition_core::actions::sessions::SessionType {
2512    fn from(value: SessionType) -> Self {
2513        match value {
2514            SessionType::Designer => Self::Designer,
2515            SessionType::Perspective => Self::Perspective,
2516            SessionType::Vision => Self::Vision,
2517        }
2518    }
2519}
2520
2521#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
2522pub enum ConnectionType {
2523    /// Database connections
2524    Database,
2525    /// OPC connections
2526    Opc,
2527}
2528
2529impl From<ConnectionType> for ignition_core::actions::connections::ConnectionType {
2530    fn from(value: ConnectionType) -> Self {
2531        match value {
2532            ConnectionType::Database => Self::Database,
2533            ConnectionType::Opc => Self::Opc,
2534        }
2535    }
2536}
2537
2538/// Import collision policy (03-02). Exactly the two values REST
2539/// exposes — `merge` is the Designer import popup's vocabulary and is
2540/// NOT a value: clap's invalid-value error lists the two real
2541/// choices, and the README documents merge as Designer-only (the
2542/// sanctioned rejection-with-hint shape).
2543#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
2544pub enum CollisionPolicy {
2545    /// Refuse when the project already exists (default)
2546    Abort,
2547    /// Replace the ENTIRE project (resources absent from the ZIP are
2548    /// deleted) — destructive, requires --yes
2549    Overwrite,
2550}
2551
2552impl From<CollisionPolicy> for ignition_core::actions::projects::CollisionPolicy {
2553    fn from(value: CollisionPolicy) -> Self {
2554        match value {
2555            CollisionPolicy::Abort => Self::Abort,
2556            CollisionPolicy::Overwrite => Self::Overwrite,
2557        }
2558    }
2559}
2560
2561#[derive(Debug, Subcommand)]
2562#[command(arg_required_else_help = true)]
2563pub enum ProfileCmd {
2564    /// Add (or overwrite) a gateway profile
2565    Add {
2566        /// Profile name
2567        name: String,
2568        /// Gateway base URL (e.g. http://localhost:9088)
2569        url: String,
2570        /// Optional display label
2571        #[arg(long, value_name = "TEXT")]
2572        label: Option<String>,
2573        /// Name of the env var holding the auth token
2574        #[arg(long, value_name = "VAR")]
2575        token_env: Option<String>,
2576        /// Keyring user string for the token (service is always ignition-cli)
2577        #[arg(long, value_name = "USER")]
2578        keyring: Option<String>,
2579        /// Name of the env var holding the basic-auth user (with --password-env)
2580        #[arg(long, value_name = "VAR")]
2581        user_env: Option<String>,
2582        /// Name of the env var holding the basic-auth password (with --user-env)
2583        #[arg(long, value_name = "VAR")]
2584        password_env: Option<String>,
2585        /// Make this profile the active one
2586        #[arg(long)]
2587        active: bool,
2588    },
2589    /// List configured profiles
2590    List,
2591    /// Switch the active profile
2592    Use {
2593        /// Profile name to activate
2594        name: String,
2595    },
2596}