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