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        /// Allow a plan that wipes tag provider PROVIDER: it deletes every
815        /// tag-definition or tag-type-definition file the ledger lists for a
816        /// provider the manifest still lists in `tag_providers`, while the
817        /// render holds none of that type (`data.provider_wipes` of `deploy
818        /// plan`). Repeatable, one provider each, and `--allow-destructive`
819        /// is needed too; without it the apply is refused
820        /// (`destructive_change_refused` gate `provider_wipe`, exit 2), with
821        /// or without `--allow-destructive`. To hand a provider to a project
822        /// repo, drop it from `tag_providers` in the commit that deletes its
823        /// tree instead: nothing is deleted then
824        #[arg(long, value_name = "PROVIDER")]
825        allow_provider_wipe: Vec<String>,
826        /// What to do about open Designer sessions: `fail` refuses while any
827        /// is open, `wait` polls until none is or `--designers-timeout`
828        /// passes, `proceed` goes ahead and records the sessions
829        #[arg(long, value_enum, value_name = "MODE", default_value = "fail")]
830        designers: DesignersArg,
831        /// Seconds `--designers wait` polls before it refuses
832        #[arg(
833            long,
834            value_name = "SECS",
835            default_value_t = 600,
836            value_parser = clap::value_parser!(u64).range(1..=86400)
837        )]
838        designers_timeout: u64,
839        /// Take no `.gwbk` backup; the run record states that none was taken
840        #[arg(long)]
841        skip_gwbk: bool,
842        /// Seconds to wait for the backup node to converge after the master
843        /// is verified; then one resync is sent to the master and the wait
844        /// repeats once. Only used when the target has nodes.backup
845        #[arg(
846            long,
847            value_name = "SECS",
848            default_value_t = 300,
849            value_parser = clap::value_parser!(u64).range(1..=86400)
850        )]
851        backup_timeout: u64,
852    },
853    /// Roll back one past run by id, from a fresh process: restore the
854    /// on-box snapshot the run took into the master node's mode collection,
855    /// delete the files the run created, scan, and record the rollback (a
856    /// run directory of its own, `kind: rollback`). The run id is
857    /// `data.run_id` of the `deploy apply` that made it, or its run
858    /// directory's name. Also recovers an interrupted run: a refusal that
859    /// prints `ign deploy rollback --run <id> --env <env>` names the id to
860    /// pass. Guarded: without the global `--yes` it exits 2
861    /// `confirmation_required` and the message names the run, the gateway,
862    /// the mode and how many files it would restore and delete. A file
863    /// changed since the run refuses (`deploy_live_changed`, exit 6) unless
864    /// `--discard-live-changes`. One fixed lock hold (176 s) serves the
865    /// rollback. Not in the MCP catalog. In human mode progress lines go to
866    /// stderr
867    Rollback {
868        /// Environment name; selects `targets/<ENV>.yaml`
869        #[arg(long, value_name = "ENV")]
870        env: String,
871        /// Declared repo root (default: the current directory). Only the
872        /// target file is read; the tree is not rendered
873        #[arg(long, value_name = "DIR", default_value = ".")]
874        repo: PathBuf,
875        /// Directory holding `targets/<ENV>.yaml` and the target's
876        /// known_hosts files (default: --repo)
877        #[arg(long, value_name = "DIR")]
878        targets: Option<PathBuf>,
879        /// The run to roll back: the `run_id` that `deploy apply` printed
880        /// (or its directory below `.ign/deploy/<gateway>/`)
881        #[arg(long, value_name = "ID")]
882        run: String,
883        /// Overwrite files changed after that run and record which. Without
884        /// it a changed file refuses the rollback (`deploy_live_changed`,
885        /// exit 6)
886        #[arg(long)]
887        discard_live_changes: bool,
888        /// What to do about open Designer sessions: `fail` refuses while any
889        /// is open, `wait` polls until none is or `--designers-timeout`
890        /// passes, `proceed` goes ahead and records the sessions
891        #[arg(long, value_enum, value_name = "MODE", default_value = "fail")]
892        designers: DesignersArg,
893        /// Seconds `--designers wait` polls before it refuses
894        #[arg(
895            long,
896            value_name = "SECS",
897            default_value_t = 600,
898            value_parser = clap::value_parser!(u64).range(1..=86400)
899        )]
900        designers_timeout: u64,
901        /// Seconds to wait for the backup node to converge after the master
902        /// is restored; then one resync is sent to the master and the wait
903        /// repeats once. Only used when the target has nodes.backup
904        #[arg(
905            long,
906            value_name = "SECS",
907            default_value_t = 300,
908            value_parser = clap::value_parser!(u64).range(1..=86400)
909        )]
910        backup_timeout: u64,
911    },
912    /// Deploy one Ignition project from its own repo to a gateway. Reads
913    /// `deploy/project.yaml` (the gateway project name and the environments
914    /// it deploys to) and the root `project.json`, reads `targets/<ENV>.yaml`,
915    /// builds the artifact from the checkout and compares it with the live
916    /// project. `--check` reports `artifact_sha256`, `live_sha256`, the action
917    /// (`create` for a project absent from the gateway, `skip` when the live
918    /// project equals the artifact and this release is recorded, `record` when
919    /// it equals the artifact but no record names this release, `import`
920    /// otherwise), the member counts and the live members an overwrite would
921    /// delete, over GET requests only; it writes nothing and creates no
922    /// `.ign/` directory. Without `--check` the deploy asks for confirmation
923    /// (`--yes`), requires `--artifact-sha256` (the hash the check reported)
924    /// for an unattended run, and `--tags-sha256` too when the project owns
925    /// tag providers, saves the live project under `.ign/deploy/`,
926    /// imports the artifact, reads the project back to compare it by
927    /// canonical hash and records the release on the gateway host. Other
928    /// projects on the gateway are listed by name and never read or changed.
929    /// Not in the MCP catalog
930    Projects {
931        /// Environment name; must be listed in `deploy/project.yaml` and
932        /// selects `targets/<ENV>.yaml`
933        #[arg(long, value_name = "ENV")]
934        env: String,
935        /// The release tag being deployed: semver after one optional leading
936        /// `v` (for example v1.4.0). A pre-release is older than its release.
937        /// Required unless `--rollback-run` is given
938        #[arg(long, value_name = "TAG", required_unless_present = "rollback_run")]
939        release: Option<String>,
940        /// Undo the latest tag-owning run of this project on this gateway
941        /// from the copy the gateway host keeps (the `run_id` a deploy
942        /// printed, or the one a `deploy_journal_present` refusal names):
943        /// the owned tags, their groups, provider settings and tag records
944        /// and the project go back to that run's pre-image. With `--check`
945        /// it lists what it would restore and writes nothing; without it it
946        /// asks for confirmation (`--yes`). Tags the run deleted are
947        /// re-created; tags it created in an existing provider are removed
948        /// through the master's tag-delete route when it is configured and
949        /// ready, and otherwise end it `rollback_failed`
950        #[arg(
951            long,
952            value_name = "RUN_ID",
953            conflicts_with_all = [
954                "release",
955                "artifact_sha256",
956                "tags_sha256",
957                "allow_downgrade",
958                "allow_first_change",
959                "allow_destructive",
960            ]
961        )]
962        rollback_run: Option<String>,
963        /// The project repo root (default: the current directory)
964        #[arg(long, value_name = "DIR", default_value = ".")]
965        repo: PathBuf,
966        /// A root holding `targets/<ENV>.yaml` (default: `--repo`). Gateway
967        /// targets may live in another repository than the project
968        #[arg(long, value_name = "DIR")]
969        targets: Option<PathBuf>,
970        /// Report what would change and write nothing; no confirmation and
971        /// no `--artifact-sha256` needed
972        #[arg(long)]
973        check: bool,
974        /// The `artifact_sha256` that `--check --json` reported for this
975        /// checkout (64 lowercase hex characters). Required without `--check`
976        /// with `--json`, `--compact` or a stdin or stdout that is not a
977        /// terminal; a different artifact is refused before anything is
978        /// uploaded
979        #[arg(long, value_name = "HASH")]
980        artifact_sha256: Option<String>,
981        /// The `data.tags.tags_sha256` that `--check --json` reported for
982        /// this checkout's owned tags (64 lowercase hex characters). Required
983        /// without `--check`, under the same rule as `--artifact-sha256`,
984        /// when `deploy/project.yaml` lists `tag_providers`; different repo
985        /// tag content is refused before anything is contacted
986        #[arg(long, value_name = "HASH")]
987        tags_sha256: Option<String>,
988        /// Let a release older than the one the gateway last received from
989        /// this tool replace it. Without it that is refused as a downgrade
990        #[arg(long)]
991        allow_downgrade: bool,
992        /// Let the first deploy of a project by this tool change what the
993        /// gateway runs, with a reason of 1 to 500 characters. Without it the
994        /// release must equal the live project
995        #[arg(long, value_name = "REASON")]
996        allow_first_change: Option<String>,
997        /// Let the deploy delete the tags the previous deploy of this
998        /// project placed and the repo no longer holds; `--check` lists them
999        /// (`data.tags.removed`). The gateway needs the tag-delete route
1000        /// (`ign webdev deploy --with-tag-delete`). Without it such a removal
1001        /// is refused (`destructive_change_refused`, exit 2); tags only on
1002        /// the gateway are never deleted
1003        #[arg(long)]
1004        allow_destructive: bool,
1005        /// What to do about open Designer sessions before an import: `fail`
1006        /// refuses while any is open, `wait` polls until none is or
1007        /// `--designers-timeout` passes, `proceed` goes ahead and records
1008        /// the sessions. A release-record-only run skips the check
1009        #[arg(long, value_enum, value_name = "MODE", default_value = "fail")]
1010        designers: DesignersArg,
1011        /// Seconds `--designers wait` polls before it refuses
1012        #[arg(
1013            long,
1014            value_name = "SECS",
1015            default_value_t = 600,
1016            value_parser = clap::value_parser!(u64).range(1..=86400)
1017        )]
1018        designers_timeout: u64,
1019        /// Seconds to wait for the backup node to hold the owned tags after
1020        /// a green run or a rollback; then one resync is sent to the master
1021        /// and the wait repeats once. Only used for a project that lists
1022        /// tag_providers on a target with nodes.backup, whose session is
1023        /// only read. A backup that does not converge ends a green run
1024        /// `applied_backup_unsynced` (exit 6) with the master left applied
1025        #[arg(
1026            long,
1027            value_name = "SECS",
1028            default_value_t = ignition_core::deploy::pair::BACKUP_TIMEOUT_DEFAULT_S,
1029            value_parser = clap::value_parser!(u64).range(1..=86400)
1030        )]
1031        backup_timeout: u64,
1032    },
1033}
1034
1035/// The `--node` words of `deploy verify` (D-12).
1036#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
1037pub enum VerifyNodeArg {
1038    /// The six master checks (the default)
1039    Master,
1040    /// Only the backup node's checks
1041    Backup,
1042    /// The master's checks, then the backup's
1043    Both,
1044}
1045
1046/// The `--designers` words of `deploy apply`, `deploy rollback` and
1047/// `deploy projects` (D-14, D-15).
1048#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
1049pub enum DesignersArg {
1050    /// Refuse while any Designer session is open (the default)
1051    Fail,
1052    /// Poll until none is open or the timeout passes, then refuse
1053    Wait,
1054    /// Go ahead, and record the open sessions
1055    Proceed,
1056}
1057
1058/// `ign edit` args (13-08): the kubectl-edit loop over ONE resource.
1059/// The resource path scopes what $EDITOR opens (the whole tree still
1060/// decodes for encode-back context); the push rides the
1061/// project-import wire behind the ONE confirmation gate. `--yes` is
1062/// the GLOBAL flag (globals-once) — never redeclared here.
1063#[derive(Debug, clap::Args)]
1064pub struct EditArgs {
1065    /// Project containing the resource
1066    pub project: String,
1067    /// Resource user path to open in $EDITOR (e.g.
1068    /// `ignition/script-python/e2e/scratch` — `ign resource list`
1069    /// names the valid members)
1070    pub resource_path: String,
1071}
1072
1073/// Resource subcommands (03-03, PROJ-05). `delete` is the family's
1074/// destructive verb (`--yes`-guarded, exit 2 without — the
1075/// sessions-terminate shape); `put` is an upsert (create-or-replace
1076/// ONE resource with explicit content) and stays friction-free per
1077/// the planner decision — agents pass exactly what they want written.
1078#[derive(Debug, clap::Args)]
1079pub struct ResourceArgs {
1080    #[command(subcommand)]
1081    pub command: ResourceCommand,
1082}
1083
1084#[derive(Debug, Subcommand)]
1085pub enum ResourceCommand {
1086    /// List a project's resources (one path per line in human mode)
1087    List {
1088        /// Project name
1089        project: String,
1090        /// Only paths under this prefix (rides the wire as the
1091        /// server-side `path` filter)
1092        #[arg(long, value_name = "PREFIX")]
1093        prefix: Option<String>,
1094    },
1095    /// Read one resource: JSON pretty-printed, text raw — binary
1096    /// (data.bin-class) resources refuse with exit 6
1097    Get {
1098        /// Project name
1099        project: String,
1100        /// Resource path, slashes kept (e.g.
1101        /// `ignition/script-python/e2e/scratch`)
1102        path: String,
1103    },
1104    /// Write one resource (upsert: created if absent, replaced if
1105    /// present) — JSON if parseable (application/json), else UTF-8
1106    /// text (text/plain); binary-looking input refuses
1107    Put {
1108        /// Project name
1109        project: String,
1110        /// Resource path
1111        path: String,
1112        /// File to read the content from, or `-` for stdin
1113        #[arg(long, value_name = "PATH")]
1114        file: String,
1115    },
1116    /// Delete one resource — destructive, refused without --yes
1117    Delete {
1118        /// Project name
1119        project: String,
1120        /// Resource path
1121        path: String,
1122    },
1123}
1124
1125/// Webdev subcommands (05-03, WEB-01/02). Deploy is deliberately NOT
1126/// `--yes`-guarded: the dedicated project (default `ign-cli`) is
1127/// CLI-OWNED — born from the first deploy zip and overwrite-replaced
1128/// on every deploy (replace-not-merge is the CONTRACT here; user
1129/// projects are never touched — README documents). scriptExec rides
1130/// only on explicit request, gated by a deploy-time generated shared
1131/// secret stored in the profile config at 0600.
1132#[derive(Debug, clap::Args)]
1133pub struct WebdevArgs {
1134    #[command(subcommand)]
1135    pub command: WebdevCommand,
1136}
1137
1138#[derive(Debug, Subcommand)]
1139pub enum WebdevCommand {
1140    /// Deploy the embedded route bundle into the dedicated project
1141    /// (overwrite-replace — the CLI owns that project wholesale)
1142    Deploy {
1143        /// Target project (default ign-cli; the CLI owns it wholesale)
1144        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1145        project: String,
1146        /// Also deploy the secret-gated scriptExec route (a fresh
1147        /// secret is generated and stored at 0600 when none exists)
1148        #[arg(long)]
1149        with_script_exec: bool,
1150        /// Generate a FRESH scriptExec secret before deploying (any
1151        /// route deployed with the old secret starts refusing)
1152        #[arg(long, requires = "with_script_exec")]
1153        rotate_secret: bool,
1154        /// Also deploy the embedded TESTING bundle — the Jython test
1155        /// framework (testing.runner & co., with the permanent
1156        /// testing.__tests__ smoke sentinel) and the testing/run +
1157        /// testing/tags WebDev routes
1158        #[arg(long)]
1159        with_testing: bool,
1160        /// Also deploy the authenticated tagDelete route (rig-only in this
1161        /// release): Basic of a user of --tag-delete-user-source holding
1162        /// --tag-delete-role, plus a generated secret stored at 0600; the
1163        /// provider allowlist is the repo's deploy/project.yaml
1164        /// tag_providers. A deploy without this flag removes the route
1165        #[arg(
1166            long,
1167            requires_all = ["tag_delete_repo", "tag_delete_user_source", "tag_delete_role"]
1168        )]
1169        with_tag_delete: bool,
1170        /// The project repo whose deploy/project.yaml names the providers
1171        /// the route may delete in (relative to the working directory).
1172        /// Repeat it to allow every repo's providers: the allowlist is the
1173        /// union, and an install that would drop a provider the live route
1174        /// allows is refused
1175        #[arg(
1176            long,
1177            value_name = "PATH",
1178            requires = "with_tag_delete",
1179            action = clap::ArgAction::Append
1180        )]
1181        tag_delete_repo: Vec<std::path::PathBuf>,
1182        /// The gateway user source the route authenticates callers against
1183        #[arg(long, value_name = "NAME", requires = "with_tag_delete")]
1184        tag_delete_user_source: Option<String>,
1185        /// The role a caller must hold
1186        #[arg(long, value_name = "ROLE", requires = "with_tag_delete")]
1187        tag_delete_role: Option<String>,
1188        /// Generate a FRESH tagDelete secret before deploying
1189        #[arg(long, requires = "with_tag_delete")]
1190        rotate_tag_delete_secret: bool,
1191    },
1192    /// Probe every route's version handshake — a READ: exit 0
1193    /// whenever the sweep completes, per-route degradation is data
1194    Status {
1195        /// Target project (default ign-cli)
1196        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1197        project: String,
1198    },
1199}
1200
1201/// Tags args (05-04, TAGS-01..04) — the grouped-subfamily pattern:
1202/// `tags provider …` nests one level deeper (the family's native
1203/// REST half), browse/read/write ride the TOP level. The
1204/// webdev-dependent arms carry `--project` (default `ign-cli`, the
1205/// same default as deploy — the deployed routes live in that
1206/// project).
1207#[derive(Debug, clap::Args)]
1208pub struct TagsArgs {
1209    #[command(subcommand)]
1210    pub command: TagsCommand,
1211}
1212
1213#[derive(Debug, Subcommand)]
1214pub enum TagsCommand {
1215    /// Manage tag providers (native config-resource REST — no
1216    /// deployed routes involved): list with tag counts + health,
1217    /// create a STANDARD provider, delete (guarded)
1218    #[command(subcommand)]
1219    Provider(TagsProviderCommand),
1220
1221    /// Browse tags as a tree (Property children filtered by
1222    /// default) — providers appear at the root; needs the deployed
1223    /// routes (`ign webdev deploy`) — or run fully OFFLINE against
1224    /// an export via --from-export
1225    Browse {
1226        /// Tag path to browse from (default: the root — providers)
1227        path: Option<String>,
1228        /// Case-insensitive substring filter on name and full path
1229        #[arg(long, value_name = "SUBSTR")]
1230        filter: Option<String>,
1231        /// Include Property children (filtered out by default)
1232        #[arg(long)]
1233        include_properties: bool,
1234        /// Project holding the deployed routes (default ign-cli)
1235        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1236        project: String,
1237        /// Browse an OFFLINE tag export instead of the gateway (no
1238        /// profile, no credential, no routes): the CLI's own `tags
1239        /// export` JSON, a legacy `<provider>.json` whole tree, or a
1240        /// git-module tags/ directory — mutually exclusive with the
1241        /// positional browse path
1242        #[arg(long, value_name = "PATH", conflicts_with_all = ["path"])]
1243        from_export: Option<PathBuf>,
1244    },
1245    /// Read one or more tag values (quality and timestamp included)
1246    /// — needs the deployed routes
1247    Read {
1248        /// Tag paths to read, e.g. `[default]T1` (one or more)
1249        #[arg(value_name = "PATH", required = true)]
1250        paths: Vec<String>,
1251        /// Project holding the deployed routes (default ign-cli)
1252        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1253        project: String,
1254    },
1255    /// Write a value to a tag — the value is parsed as a JSON
1256    /// scalar (number/bool/null); anything unparseable is sent as a
1257    /// string; arrays/objects refuse — needs the deployed routes
1258    Write {
1259        /// Tag path to write, e.g. `[default]T1`
1260        path: String,
1261        /// Value to write: JSON scalar (42, 1.5, true), else the raw
1262        /// text is sent as a string
1263        #[arg(long, value_name = "V")]
1264        value: String,
1265        /// Project holding the deployed routes (default ign-cli)
1266        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1267        project: String,
1268    },
1269    /// Tag CONFIGURATION CRUD (the surgical edit loop — JSON in,
1270    /// JSON out): get/create/edit/delete tag configs through the
1271    /// deployed tagConfig route; stringified values are re-parsed so
1272    /// agents see real JSON
1273    #[command(subcommand)]
1274    Config(TagsConfigCommand),
1275    /// UDT types and definitions (recursive: parameters + nested
1276    /// children) — needs the deployed routes
1277    #[command(subcommand)]
1278    Udt(TagsUdtCommand),
1279    /// Alarm operations: active status (with filters), journal
1280    /// history, acknowledge — needs the deployed routes
1281    #[command(subcommand)]
1282    Alarms(TagsAlarmsCommand),
1283    /// Tag history queries (historian-backed values) — needs the
1284    /// deployed routes; structurally safe on any rig (data requires
1285    /// a provisioned historian)
1286    #[command(subcommand)]
1287    History(TagsHistoryCommand),
1288    /// Export tag subtrees — json writes the gateway's native JSON
1289    /// interchange (the lossless round-trip); xml passes the
1290    /// gateway's own XML through byte-for-byte; csv is CLI-GENERATED
1291    /// and LOSSY (no alarms, legacy 48-column set, numeric enums —
1292    /// the gateway cannot export CSV)
1293    Export {
1294        /// Tag paths to export, e.g. `[default]P5` (one or more)
1295        #[arg(value_name = "PATH", required = true)]
1296        paths: Vec<String>,
1297        /// Output file (`-` = stdout — the raw payload, no envelope;
1298        /// default: `<last-path-segment>.<ext>` in the cwd, ext per
1299        /// format)
1300        #[arg(short = 'o', long, value_name = "FILE")]
1301        output: Option<PathBuf>,
1302        /// Payload format: json (the gateway's native interchange —
1303        /// default), xml (raw gateway bytes), or csv (CLI-generated
1304        /// and LOSSY: no alarms, legacy columns only, numeric enum
1305        /// coercion — what was dropped prints on stderr)
1306        #[arg(long, value_enum, default_value_t = TransferFormat::Json, value_name = "FORMAT")]
1307        format: TransferFormat,
1308        /// Project holding the deployed routes (default ign-cli)
1309        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1310        project: String,
1311    },
1312    /// Import a tag export into a target provider — json is the
1313    /// native interchange; xml/csv ride the gateway's importTags
1314    /// passthrough. A loss scan reports what xml/csv would drop or
1315    /// coerce BEFORE the import and refuses exit 2 unless --yes.
1316    /// Collision behavior: abort (default) refuses on collisions;
1317    /// overwrite replaces them (destructive: requires --yes)
1318    Import {
1319        /// The export file to import (`-` = stdin)
1320        #[arg(long, value_name = "FILE")]
1321        file: PathBuf,
1322        /// Target tag provider (must exist — `ign tags provider
1323        /// create NAME` first)
1324        #[arg(long, value_name = "NAME")]
1325        provider: String,
1326        /// Payload format: json (the gateway's native interchange —
1327        /// default), xml (gateway XML), or csv (legacy CSV — alarms
1328        /// never arrive; the loss scan reports the gap)
1329        #[arg(long, value_enum, default_value_t = TransferFormat::Json, value_name = "FORMAT")]
1330        format: TransferFormat,
1331        /// Collision policy: abort refuses when tags already exist
1332        /// (default); overwrite replaces them (destructive:
1333        /// requires --yes). merge is Designer-only (not a value)
1334        #[arg(long, value_enum, default_value_t = CollisionPolicy::Abort)]
1335        collision_policy: CollisionPolicy,
1336        /// Project holding the deployed routes (default ign-cli)
1337        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1338        project: String,
1339    },
1340}
1341
1342/// The bulk-transfer payload format (11-05) — the clap surface for
1343/// core's `ExportFormat`/`ImportFormat` (core keeps plain enums; the
1344/// From impls live beside this one, the CollisionPolicy precedent).
1345/// json is the default so every pre-11-05 invocation parses and
1346/// behaves byte-identically.
1347#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
1348pub enum TransferFormat {
1349    /// The gateway's JSON interchange (default — lossless)
1350    #[default]
1351    Json,
1352    /// Gateway XML — RAW-BYTE passthrough both directions (no
1353    /// parse/normalize in the transfer path)
1354    Xml,
1355    /// Legacy CSV — import: gateway passthrough (alarms never
1356    /// arrive); export: CLI-GENERATED and LOSSY (the gateway cannot
1357    /// export CSV)
1358    Csv,
1359}
1360
1361impl std::fmt::Display for TransferFormat {
1362    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1363        let label = match self {
1364            Self::Json => "json",
1365            Self::Xml => "xml",
1366            Self::Csv => "csv",
1367        };
1368        f.write_str(label)
1369    }
1370}
1371
1372/// `tags config …` — the configuration CRUD subfamily (05-05,
1373/// TAGS-05): the get→edit-file→write-back surgical loop. Definition
1374/// files are the configure shape (README's traps table: tagType
1375/// discriminator, nested children, alarms-as-LIST).
1376#[derive(Debug, Subcommand)]
1377pub enum TagsConfigCommand {
1378    /// Get a tag's configuration as (pretty) JSON — stringified
1379    /// value/defaultValue sub-dicts re-parsed into real JSON
1380    Get {
1381        /// Tag path, e.g. `[default]P5/T1`
1382        path: 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    /// Create a tag from a JSON definition file (`-` = stdin) —
1388    /// aborts on an existing node (collision policy 'a')
1389    Create {
1390        /// Tag path to create, e.g. `[default]P5/T1`
1391        path: String,
1392        /// JSON definition file (`-` = stdin): `{tagType, value,
1393        /// alarms, …}` — see the README configure-shape table
1394        #[arg(long, value_name = "FILE")]
1395        file: PathBuf,
1396        /// Project holding the deployed routes (default ign-cli)
1397        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1398        project: String,
1399    },
1400    /// Edit a tag's configuration from a JSON definition file (`-` =
1401    /// stdin) — overwrites that single node (collision policy 'o')
1402    Edit {
1403        /// Tag path to edit, e.g. `[default]P5/T1`
1404        path: String,
1405        /// JSON definition file (`-` = stdin)
1406        #[arg(long, value_name = "FILE")]
1407        file: PathBuf,
1408        /// Project holding the deployed routes (default ign-cli)
1409        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1410        project: String,
1411    },
1412    /// Delete tag configurations — destructive, refused without
1413    /// --yes (the guard fires before ANY resolution: zero network
1414    /// work)
1415    Delete {
1416        /// Tag paths to delete (one or more)
1417        #[arg(value_name = "PATH", required = true)]
1418        paths: Vec<String>,
1419        /// Project holding the deployed routes (default ign-cli)
1420        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1421        project: String,
1422    },
1423}
1424
1425/// `tags udt …` — the UDT subfamily (05-05, TAGS-06).
1426#[derive(Debug, Subcommand)]
1427pub enum TagsUdtCommand {
1428    /// List the provider's UDT types
1429    Types {
1430        /// Tag provider whose `_types_` folder to browse (default
1431        /// `default`)
1432        #[arg(long, default_value = "default", value_name = "NAME")]
1433        provider: String,
1434        /// Project holding the deployed routes (default ign-cli)
1435        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1436        project: String,
1437    },
1438    /// Get a UDT definition (parameters + nested children, recursive)
1439    Def {
1440        /// UDT type name, e.g. `Motor`
1441        name: String,
1442        /// Tag provider (default `default`)
1443        #[arg(long, default_value = "default", value_name = "NAME")]
1444        provider: String,
1445        /// Project holding the deployed routes (default ign-cli)
1446        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1447        project: String,
1448    },
1449}
1450
1451#[derive(Debug, Subcommand)]
1452pub enum TagsProviderCommand {
1453    /// List the gateway's tag providers (tag counts + health)
1454    List,
1455    /// Create a STANDARD tag provider (DB-backed providers are out
1456    /// of scope at MVP)
1457    Create {
1458        /// Provider name to create
1459        name: String,
1460    },
1461    /// Delete a tag provider — destructive, refused without --yes
1462    Delete {
1463        /// Provider name to delete
1464        name: String,
1465    },
1466}
1467
1468/// `tags alarms …` — the alarm subfamily (05-06, TAGS-07). History
1469/// needs a JOURNAL-PROVISIONED gateway (database connection +
1470/// alarm-journal profile + general-alarm-settings — default rigs
1471/// refuse exit 6 with the provisioning hint); acknowledge is the
1472/// gateway-scope 3-arg form, so `--username` is REQUIRED (no
1473/// default-guessing) and ack is deliberately NOT `--yes`-guarded
1474/// (acknowledging never un-acknowledges anything — a state-advancing
1475/// read-adjacent verb).
1476#[derive(Debug, Subcommand)]
1477pub enum TagsAlarmsCommand {
1478    /// List active alarms — eventId/source/state/priority/name
1479    Active {
1480        /// Filter by alarm source (e.g. `prov:default`)
1481        #[arg(long, value_name = "SOURCE")]
1482        source: Option<String>,
1483        /// Filter by priority (e.g. `High`)
1484        #[arg(long, value_name = "PRIORITY")]
1485        priority: Option<String>,
1486        /// Filter by state (e.g. `Active, Unacknowledged`)
1487        #[arg(long, value_name = "STATE")]
1488        state: Option<String>,
1489        /// Project holding the deployed routes (default ign-cli)
1490        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1491        project: String,
1492    },
1493    /// Query alarm history — requires a journal-provisioned gateway
1494    /// (default rigs refuse with the provisioning hint naming the
1495    /// missing chain)
1496    History {
1497        /// Window start: RFC3339 timestamp or epoch-ms
1498        #[arg(long, value_name = "T")]
1499        start: String,
1500        /// Window end: RFC3339 timestamp or epoch-ms
1501        #[arg(long, value_name = "T")]
1502        end: String,
1503        /// Project holding the deployed routes (default ign-cli)
1504        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1505        project: String,
1506    },
1507    /// Acknowledge alarms (explicit --username: the 3-arg wire form
1508    /// needs it). NOT --yes-guarded — acknowledging never
1509    /// un-acknowledges anything
1510    Ack {
1511        /// Alarm event ids to acknowledge (from `alarms active`)
1512        #[arg(value_name = "ID", required = true)]
1513        ids: Vec<String>,
1514        /// Acknowledgement note (default: empty)
1515        #[arg(long, value_name = "NOTE")]
1516        note: Option<String>,
1517        /// The username acknowledging (REQUIRED — the 3-arg wire
1518        /// form needs it; the CLI never guesses one)
1519        #[arg(long, value_name = "NAME")]
1520        username: String,
1521        /// Project holding the deployed routes (default ign-cli)
1522        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1523        project: String,
1524    },
1525}
1526
1527/// `tags history …` — the historian subfamily (05-06, TAGS-08).
1528#[derive(Debug, Subcommand)]
1529pub enum TagsHistoryCommand {
1530    /// Query historical tag values — t_stamp + one column per tag;
1531    /// structurally safe anywhere (data requires a provisioned
1532    /// historian)
1533    Query {
1534        /// Tag paths to query, e.g. `[default]T1` (one or more)
1535        #[arg(value_name = "PATH", required = true)]
1536        paths: Vec<String>,
1537        /// Window start: RFC3339 timestamp or epoch-ms
1538        #[arg(long, value_name = "T")]
1539        start: String,
1540        /// Window end: RFC3339 timestamp or epoch-ms
1541        #[arg(long, value_name = "T")]
1542        end: String,
1543        /// Maximum rows returned
1544        #[arg(long, value_name = "N")]
1545        return_size: Option<i64>,
1546        /// Aggregation mode (e.g. `LastValue`, `Average`, `MinMax`);
1547        /// default is the route's LastValue
1548        #[arg(long, value_name = "MODE")]
1549        aggregation: Option<String>,
1550        /// Project holding the deployed routes (default ign-cli)
1551        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1552        project: String,
1553    },
1554}
1555
1556/// Rig args (04-01, RIG-01): `--rig` rides the TOP level (the
1557/// SessionsArgs `--type` precedent) so every rig verb shares it. The
1558/// command enum carries ONLY the wired verbs — later plans extend it
1559/// one variant at a time (the established extend-per-plan chore; no
1560/// `unimplemented!()` stubs, every commit compiles clippy-clean).
1561#[derive(Debug, clap::Args)]
1562pub struct RigArgs {
1563    /// Rig to operate on (default: IGNITION_RIG, then [rig].default,
1564    /// then the cwd/convention scan)
1565    #[arg(long, value_name = "NAME")]
1566    pub rig: Option<String>,
1567
1568    #[command(subcommand)]
1569    pub command: RigCommand,
1570}
1571
1572#[derive(Debug, Subcommand)]
1573pub enum RigCommand {
1574    /// Bring the rig up (compose up -d --wait) and wait for the
1575    /// gateway: RUNNING, or uncommissioned-as-data (exit 0 + wizard
1576    /// hint in warnings)
1577    Up {
1578        /// Wait budget in seconds — BOTH compose's --wait-timeout and
1579        /// the commissioned probe deadline (default 300)
1580        #[arg(long, default_value_t = 300, value_name = "SECS")]
1581        timeout: u64,
1582
1583        /// Provision a third-party module for THIS invocation only
1584        /// (repeatable): `ID@VERSION`, e.g. `git@2.3.4`. ADDITIVE to
1585        /// any `[rigs.NAME.modules.*]` declared in config — a flag
1586        /// naming an id config already declares OVERRIDES that id's
1587        /// version for this run; there is NO subtractive form (a
1588        /// config-declared module cannot be omitted via this flag —
1589        /// remove it from config instead)
1590        #[arg(long, value_name = "ID@VERSION")]
1591        with_module: Vec<String>,
1592
1593        /// Re-verify every already-cached module version against its
1594        /// release feed before trusting it: an unchanged digest reuses
1595        /// the cache, a changed one REFUSES (exit 6) naming both
1596        /// digests — never silently accepted. Mutually exclusive with
1597        /// --accept-upstream-change (never implied by it)
1598        #[arg(long, conflicts_with = "accept_upstream_change")]
1599        refresh: bool,
1600
1601        /// Accept a changed digest the feed now reports for an
1602        /// already-cached module version — the deliberate, explicit
1603        /// way to accept a re-released artifact (downloads and
1604        /// verifies the NEW bytes, cached alongside the old entry,
1605        /// never over it). Mutually exclusive with --refresh
1606        #[arg(long, conflicts_with = "refresh")]
1607        accept_upstream_change: bool,
1608    },
1609    /// Stop the rig (compose down --remove-orphans; volumes KEPT —
1610    /// `reset` owns the teardown half)
1611    Down,
1612    /// Tear the rig down AND remove its volumes (down -v
1613    /// --remove-orphans), then bring it back up fresh — destructive,
1614    /// refused without --yes; no stale project/trial state survives
1615    Reset {
1616        /// Wait budget in seconds — BOTH compose's --wait-timeout and
1617        /// the commissioned probe deadline (default 300)
1618        #[arg(long, default_value_t = 300, value_name = "SECS")]
1619        timeout: u64,
1620
1621        /// Provision a third-party module for THIS invocation only
1622        /// (repeatable): `ID@VERSION`, e.g. `git@2.3.4`. Same
1623        /// additive/flag-wins/no-subtractive-form contract as `rig up
1624        /// --with-module` (`reset` runs the same up-half internally)
1625        #[arg(long, value_name = "ID@VERSION")]
1626        with_module: Vec<String>,
1627
1628        /// Re-verify every already-cached module version against its
1629        /// release feed before trusting it (see `rig up --refresh`).
1630        /// Mutually exclusive with --accept-upstream-change
1631        #[arg(long, conflicts_with = "accept_upstream_change")]
1632        refresh: bool,
1633
1634        /// Accept a changed digest the feed now reports for an
1635        /// already-cached module version (see `rig up
1636        /// --accept-upstream-change`). Mutually exclusive with --refresh
1637        #[arg(long, conflicts_with = "refresh")]
1638        accept_upstream_change: bool,
1639    },
1640    /// Structured status: services, ports, volumes (allowlist JSON;
1641    /// a down rig is exit-0 data)
1642    Status,
1643    /// Stream the rig's container logs (compose logs passthrough —
1644    /// raw lines, no envelope in any mode; the third streaming
1645    /// exception, README-documented)
1646    Logs {
1647        /// Lines to show from the end of each service's logs
1648        #[arg(long, default_value_t = 200, value_name = "N")]
1649        tail: u32,
1650        /// Follow: stream new lines as they occur (Ctrl-C stops —
1651        /// default process kill, no envelope)
1652        #[arg(short = 'f', long)]
1653        follow: bool,
1654        /// One service's logs only (see `ign rig status` for names)
1655        service: Option<String>,
1656    },
1657    /// Trial-license state: status is credential-free truth; reset
1658    /// (guarded) restarts an EXPIRED trial via the mechanism ladder
1659    /// (token-auth POST, else native gateway login)
1660    Trial(TrialArgs),
1661    /// Third-party module lifecycle beyond declaration: `uninstall`
1662    /// removes a module from the gateway entirely — guarded, and NOT
1663    /// undone by re-declaring it (Phase 19)
1664    Module(ModuleArgs),
1665    /// Snapshot the rig's gateway: native gwbk (roaming backup,
1666    /// streamed) + per-project exports + manifest.json, composed in a
1667    /// timestamped directory — repeatable state
1668    Snapshot {
1669        /// Output directory (default:
1670        /// ./ign-rig-snapshots/<rig>-<yyyyMMdd-HHmmss>/)
1671        #[arg(short = 'o', long, value_name = "DIR")]
1672        output: Option<PathBuf>,
1673    },
1674    /// Restore a gwbk onto the rig's gateway — destructive, refused
1675    /// without --yes; synchronous restore + restart, then a witnessed
1676    /// RUNNING wait
1677    Restore {
1678        /// The gwbk file to restore (from `ign rig snapshot`)
1679        #[arg(long, value_name = "PATH")]
1680        file: PathBuf,
1681        /// Post-restore RUNNING wait budget in seconds (floored at
1682        /// 300 — the gateway restarts after a restore)
1683        #[arg(long, default_value_t = 300, value_name = "SECS")]
1684        timeout: u64,
1685    },
1686}
1687
1688/// Trial subcommands (04-03, RIG-02/03). `status` reads the
1689/// unauthenticated trial endpoint + banners cross-check. `reset` is
1690/// the family's destructive verb (`--yes`-guarded, exit 2 without —
1691/// the reset precedent); the password NEVER rides a flag (env/secret
1692/// only — redaction discipline).
1693#[derive(Debug, clap::Args)]
1694pub struct TrialArgs {
1695    #[command(subcommand)]
1696    pub command: TrialCommand,
1697}
1698
1699#[derive(Debug, Clone, Subcommand)]
1700pub enum TrialCommand {
1701    /// Show the trial state: licenseMode, trialState, seconds left,
1702    /// expired — plus the banners cross-check. No credential needed
1703    /// (the endpoints answer unauthenticated — fresh-rig friendly)
1704    Status,
1705    /// Reset an EXPIRED trial to a fresh window — destructive,
1706    /// refused without --yes. Mechanism ladder: API-token POST
1707    /// (IGNITION_TOKEN) → native gateway login (--user /
1708    /// IGNITION_USER + IGNITION_PASSWORD). Non-expired trials refuse
1709    /// (trial_not_expired)
1710    Reset {
1711        /// Gateway admin username for the login rung (password comes
1712        /// from IGNITION_PASSWORD — never a flag)
1713        #[arg(long, value_name = "NAME")]
1714        user: Option<String>,
1715    },
1716}
1717
1718/// Module subcommands (Phase 19) — `uninstall` is the family's ONE
1719/// destructive verb: guarded (`--yes`-refused, the `rig reset`
1720/// precedent), and, unlike every other guarded verb in this codebase,
1721/// NOT undone by re-declaring the module afterward (recovery today
1722/// means `ign rig reset`, which destroys the rig's data volume).
1723#[derive(Debug, clap::Args)]
1724pub struct ModuleArgs {
1725    #[command(subcommand)]
1726    pub command: ModuleCommand,
1727}
1728
1729#[derive(Debug, Clone, Subcommand)]
1730pub enum ModuleCommand {
1731    /// Remove a module from the gateway entirely — destructive, refused
1732    /// without `--yes`. `ID` is a REGISTRY id (the same one `rig up
1733    /// --with-module` takes; see `ign rig up --with-module`), never a
1734    /// raw gateway module id — a hand-installed module cannot be named
1735    /// here. Needs `IGNITION_TOKEN`. The gateway refuses while the
1736    /// `.modl` is still mounted: undeclare the module and run `ign rig
1737    /// up` first. A successful uninstall is NOT reversible by
1738    /// re-declaring — recovery today means `ign rig reset --yes`,
1739    /// which destroys the rig's data volume.
1740    Uninstall {
1741        /// The registry id to uninstall (e.g. `git`).
1742        id: String,
1743    },
1744}
1745
1746/// Backup subcommands (07-02, BKUP-01) — the standalone surface of
1747/// the Phase 4 gwbk wire. `restore` is the family's destructive verb
1748/// (the 8th `--yes`-guarded set member: it REPLACES this gateway's
1749/// state); `download` is a streamed read, unguarded.
1750#[derive(Debug, clap::Args)]
1751pub struct BackupArgs {
1752    #[command(subcommand)]
1753    pub command: BackupCommand,
1754}
1755
1756#[derive(Debug, Subcommand)]
1757pub enum BackupCommand {
1758    /// Download a gwbk backup (streamed to disk)
1759    Download {
1760        /// Output file (default: the gateway's Content-Disposition
1761        /// name, else <profile>-backup.gwbk)
1762        #[arg(short, long, value_name = "FILE")]
1763        output: Option<PathBuf>,
1764        /// Backup type: roaming = portable across gateways (default);
1765        /// all includes gateway-specific state
1766        #[arg(long, value_enum, default_value_t = CliBackupType::Roaming)]
1767        r#type: CliBackupType,
1768    },
1769    /// Restore a gwbk onto THIS gateway — destructive, refused
1770    /// without --yes; the gateway restarts and blocks for minutes
1771    /// after the restore
1772    Restore {
1773        /// The gwbk file to restore (from `ign backup download` or
1774        /// `ign rig snapshot`)
1775        file: PathBuf,
1776    },
1777}
1778
1779/// CLI value-enum mirror of the core `BackupType` (ignition-core
1780/// stays clap-free; `From` converts at the dispatch seam).
1781#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
1782pub enum CliBackupType {
1783    /// Portable backup (cross-gateway)
1784    Roaming,
1785    /// Includes gateway-specific state
1786    All,
1787}
1788
1789impl From<CliBackupType> for ignition_core::client::backup::BackupType {
1790    fn from(value: CliBackupType) -> Self {
1791        match value {
1792            CliBackupType::Roaming => Self::Roaming,
1793            CliBackupType::All => Self::All,
1794        }
1795    }
1796}
1797
1798/// EAM subcommands (07-02, BKUP-02) — the read-heavy surface with
1799/// guarded writes. Every runtime verb honestly reports the
1800/// controller-mode state gate (`eam_not_controller` on a stock
1801/// gateway — the README documents the manual flip).
1802#[derive(Debug, clap::Args)]
1803pub struct EamArgs {
1804    #[command(subcommand)]
1805    pub command: EamCommand,
1806}
1807
1808#[derive(Debug, Subcommand)]
1809pub enum EamCommand {
1810    /// EAM task run history (the gateway's own newest-first order)
1811    History {
1812        /// Max entries (default 200 — ALWAYS sent explicitly; the
1813        /// server default is unlimited)
1814        #[arg(long, value_name = "N")]
1815        limit: Option<u32>,
1816        /// Substring search over task names
1817        #[arg(long, value_name = "TEXT")]
1818        search: Option<String>,
1819    },
1820    /// Task definitions: bare `ign eam tasks` lists; with a name
1821    /// shows one definition + its scheduled state
1822    Tasks {
1823        /// One task definition's detail (omit to list all)
1824        name: Option<String>,
1825    },
1826    /// Task definition writes (create; force-dispatch) — `eam task`
1827    /// requires a subcommand (the `rig trial` shape)
1828    #[command(subcommand)]
1829    Task(EamTaskCommand),
1830}
1831
1832/// `eam task …` — the guarded writes (07-02 Task 3; the five
1833/// lifecycle/mutation verbs arrive 10-04). `new` carries the typed
1834/// guard ladder (backup+OnDemand unguarded; mutating types and any
1835/// non-OnDemand schedule need --yes; restore/install/upgrade types
1836/// refuse outright — see README); `force` dispatches NOW, always
1837/// guarded; `suspend`/`resume`/`cancel`/`modify`/`delete` are the
1838/// two-tier guarded verbs (pure precheck → blast-radius preview
1839/// fetch → confirm-with-preview → action — the refusal message IS
1840/// the blast radius).
1841#[derive(Debug, Subcommand)]
1842pub enum EamTaskCommand {
1843    /// Create a task definition (scheduleMode defaults to OnDemand —
1844    /// never auto-fires)
1845    New {
1846        /// Task definition name (any resource name — e.g. nightly-backup)
1847        name: String,
1848        /// Task type — the openapi taxonomy, three classes with different guard consequences:
1849        ///
1850        /// - benign (no --yes with the default OnDemand schedule): eam_backup
1851        ///
1852        /// - mutating (need --yes — they act on their agent targets when dispatched):
1853        ///   eam_restart, eam_sendProject, eam_sendResource, eam_sendTags,
1854        ///   eam_activateLicense, eam_updateLicense, eam_unactivateLicense
1855        ///
1856        /// - refused (exit 6 eam_task_type_refused — fleet-destructive; run from the EAM console):
1857        ///   eam_restoreBackup, eam_installModules, eam_remoteUpgrade
1858        ///
1859        /// Any OTHER type fails safe to the --yes rung (unknown future
1860        /// types stay accepted, never silently unguarded).
1861        ///
1862        /// Example: ign eam task new nightly-backup eam_backup --target gw-a
1863        r#type: String,
1864        /// Target gateway name (repeatable; the GNET agent id)
1865        #[arg(long, value_name = "NAME")]
1866        target: Vec<String>,
1867        /// Setting as K=V with scalar auto-typing — bool/int ride
1868        /// typed, anything else stays a string (repeatable;
1869        /// arrays/objects need --definition)
1870        #[arg(long, value_name = "K=V")]
1871        setting: Vec<String>,
1872        /// Full-JSON settings file deep-merged over the composed
1873        /// `config.settings` (the typed/array settings path)
1874        #[arg(long, value_name = "PATH", conflicts_with = "setting")]
1875        definition: Option<PathBuf>,
1876        /// Schedule mode (default OnDemand — never auto-fires;
1877        /// Immediate/Scheduled/AtTime/AtDelay require --yes)
1878        #[arg(long, value_enum, default_value_t = ScheduleMode::OnDemand)]
1879        schedule_mode: ScheduleMode,
1880    },
1881    /// Force-dispatch a task NOW — destructive, refused without
1882    /// --yes (it dispatches to the agent targets immediately). The
1883    /// confirmation prompt (and every refusal) carries the
1884    /// blast-radius preview line.
1885    Force {
1886        /// Task definition name to dispatch
1887        name: String,
1888    },
1889    /// Suspend a task definition's scheduled dispatches — TASK-scoped
1890    /// runtime verb (there is NO agent-level suspend on the wire; an
1891    /// "agent" is suspended by suspending its tasks). Refused exit 2
1892    /// without --yes, the blast-radius preview in the refusal
1893    /// message; on a stock gateway the runtime seam honestly refuses
1894    /// `eam_not_controller` (exit 6).
1895    Suspend {
1896        /// Task definition name to suspend
1897        name: String,
1898    },
1899    /// Resume a suspended task definition — the suspend inverse
1900    /// (TASK-scoped, like suspend). Refused exit 2 without --yes
1901    /// with the blast-radius preview in the message.
1902    Resume {
1903        /// Task definition name to resume
1904        name: String,
1905    },
1906    /// Cancel a task's PENDING execution (TASK-scoped). Refused exit
1907    /// 2 without --yes with the blast-radius preview in the message;
1908    /// nothing pending is an honest no-op (`fired: false`), a
1909    /// `canCancel: false` row reports the gateway's own refusal.
1910    Cancel {
1911        /// Task definition whose pending execution to cancel
1912        name: String,
1913    },
1914    /// Rewrite targeted keys of a task definition — the full-record
1915    /// read-modify-write (every key the gateway answered rides back;
1916    /// only the targeted keys change). Rename is deliberately
1917    /// ABSENT (the wire answers PUT-rename with 404 — see README's
1918    /// create-new + delete-old composite). Refused exit 2 without
1919    /// --yes with the blast-radius preview in the message.
1920    ///
1921    /// Example: ign eam task modify nightly-backup --enable
1922    Modify {
1923        /// Task definition to modify
1924        name: String,
1925        /// Flip the definition's `enabled` key to true
1926        #[arg(long)]
1927        enable: bool,
1928        /// Flip the definition's `enabled` key to false
1929        /// (conflicts with --enable)
1930        #[arg(long, conflicts_with = "enable")]
1931        disable: bool,
1932        /// Rewrite `config.profile.scheduleMode` (e.g. OnDemand,
1933        /// Scheduled — the scheduleDetails string itself is out of
1934        /// scope; the read-back shows the landed pairing)
1935        #[arg(long, value_name = "MODE")]
1936        schedule_mode: Option<String>,
1937        /// Deep-merge a settings entry over the found
1938        /// `config.settings` (K=V with scalar auto-typing;
1939        /// repeatable — objects merge recursively, arrays/scalars
1940        /// replace)
1941        #[arg(long, value_name = "K=V")]
1942        setting: Vec<String>,
1943        /// Rewrite the definition's `description`
1944        #[arg(long, value_name = "TEXT")]
1945        description: Option<String>,
1946    },
1947    /// Delete a task definition — signature-keyed (the gateway
1948    /// refuses a stale signature), behind the same --yes guard.
1949    /// Refused exit 2 without --yes with the blast-radius preview
1950    /// in the message.
1951    Delete {
1952        /// Task definition to delete
1953        name: String,
1954    },
1955}
1956
1957/// Schedule modes (the openapi taxonomy's user-facing subset —
1958/// SuspendedByFailover is system-owned, not a CLI value).
1959#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
1960pub enum ScheduleMode {
1961    /// Never fires on its own (force dispatches it)
1962    OnDemand,
1963    /// Fires immediately on create (requires --yes)
1964    Immediate,
1965    /// Fires on a schedule (requires --yes)
1966    Scheduled,
1967    /// Fires at an absolute time (requires --yes)
1968    AtTime,
1969    /// Fires after a delay (requires --yes)
1970    AtDelay,
1971}
1972
1973impl ScheduleMode {
1974    /// The wire token.
1975    pub fn wire(self) -> &'static str {
1976        match self {
1977            Self::OnDemand => "OnDemand",
1978            Self::Immediate => "Immediate",
1979            Self::Scheduled => "Scheduled",
1980            Self::AtTime => "AtTime",
1981            Self::AtDelay => "AtDelay",
1982        }
1983    }
1984}
1985
1986/// Script subcommands (07-03, SCRPT-01) — the smallest family: ONE
1987/// verb over the already-shipped, already-secured scriptExec route.
1988/// `script` requires a subcommand (the `rig trial` shape — no bare
1989/// row); the opt-in is STRUCTURAL (the route deploys only via
1990/// `ign webdev deploy --with-script-exec`, which persists the
1991/// secret at 0600), so `run` carries NO `--yes` guard by design —
1992/// the deploy flag IS the opt-in and agents need the verb
1993/// non-interactive (the research-adopted decision).
1994#[derive(Debug, clap::Args)]
1995pub struct ScriptArgs {
1996    #[command(subcommand)]
1997    pub command: ScriptCommand,
1998}
1999
2000/// Testing subcommands (QUICK-p0g) — the standalone wrapper over the
2001/// gateway testing bundle's `testing/run` route. `testing` requires a
2002/// subcommand (the `script` shape — no bare row); there is no `--yes`
2003/// guard: running a test suite is a read-shaped operation from the
2004/// CLI's side and agents need the verb non-interactive.
2005#[derive(Debug, clap::Args)]
2006pub struct TestingArgs {
2007    #[command(subcommand)]
2008    pub command: TestingCommand,
2009}
2010
2011/// `ign e2e` args (QUICK-tg4) — the browser-E2E pair.
2012#[derive(Debug, clap::Args)]
2013pub struct E2eArgs {
2014    #[command(subcommand)]
2015    pub command: E2eCmd,
2016}
2017
2018#[derive(Debug, Subcommand)]
2019pub enum E2eCmd {
2020    /// Diagnose the browser-E2E setup: node (≥20), npm,
2021    /// @playwright/test in the scaffold, a downloaded chromium, the
2022    /// gateway's testing bundle, and the gateway's trial state.
2023    /// Read-only and offline-safe — **exits 0 whenever the diagnosis
2024    /// completes**, so every finding is a `checks[]` row rather than
2025    /// an exit code. The two gateway rows report `skip` when no
2026    /// profile resolves or no --project is given
2027    Doctor {
2028        /// The scaffold directory to inspect (default: ./e2e)
2029        #[arg(value_name = "DIR")]
2030        dir: Option<PathBuf>,
2031        /// The gateway project whose testing bundle is probed. No
2032        /// default — without it the bundle row honestly skips
2033        #[arg(long, value_name = "NAME")]
2034        project: Option<String>,
2035    },
2036
2037    /// Scaffold a Playwright E2E suite into DIR (default ./e2e): a
2038    /// runner config, a global setup that logs in through `ign
2039    /// session login` and gates on `ign testing run`, gateway tag/
2040    /// script helpers, an example suite, a .gitignore, and a README —
2041    /// then `npm install`. **Idempotent**: an existing file is
2042    /// reported `skipped` and never overwritten. Requires the global
2043    /// --yes; without it the verb writes nothing, spawns nothing, and
2044    /// refuses exit 2 after previewing every file and both commands
2045    Init {
2046        /// Where the scaffold lands (default: ./e2e)
2047        #[arg(value_name = "DIR")]
2048        dir: Option<PathBuf>,
2049        /// The project whose WebDev testing routes the helpers call
2050        /// (default: ign-cli)
2051        #[arg(long, value_name = "NAME")]
2052        project: Option<String>,
2053        /// The Perspective project the browser test navigates to
2054        /// (default: the effective --project)
2055        #[arg(long, value_name = "NAME")]
2056        run_project: Option<String>,
2057        /// Also download the Chromium build after installing
2058        #[arg(long)]
2059        browsers: bool,
2060    },
2061}
2062
2063/// `ign session` args (QUICK-tg4) — the SINGULAR verb family: one
2064/// gateway login session for this CLI to hand to a browser harness.
2065///
2066/// The plural `ign sessions` family (`SessionsArgs` / `SessionsCommand`)
2067/// is a different thing entirely: it LISTS the gateway's connected
2068/// designer/Perspective/Vision sessions. The names are deliberately
2069/// kept apart here so a future reader never merges them.
2070#[derive(Debug, clap::Args)]
2071pub struct SessionArgs {
2072    #[command(subcommand)]
2073    pub command: SessionCmd,
2074}
2075
2076#[derive(Debug, Subcommand)]
2077pub enum SessionCmd {
2078    /// Log in to the gateway and return the live session: the
2079    /// `webui-sid-*` cookie name and value, the CSRF token for
2080    /// `X-CSRF-Token`, the gateway URL, and `storage_state` — a
2081    /// Playwright `storageState` document ready to write straight to
2082    /// disk and point `use.storageState` at. The password is env-only
2083    /// (`IGNITION_PASSWORD`; missing exits 3, rejected exits 5). The
2084    /// cookie value and CSRF token are exposed ONLY under the global
2085    /// --json flag
2086    Login {
2087        /// Gateway login user (default: $IGNITION_USER, else admin)
2088        #[arg(long, value_name = "NAME")]
2089        user: Option<String>,
2090    },
2091}
2092
2093/// The CLI-side `--format` enum (QUICK-p0g) — the clap `ValueEnum`
2094/// mirror of core's plain `TestingFormat`, the `TransferFormat`
2095/// precedent (core keeps framework-free enums; the `From` impl lives
2096/// beside this one). `get_possible_values()` is what feeds the MCP
2097/// tool schema a real enum instead of a free string.
2098///
2099/// EVERY value produces the SAME verdict and exit code: the route is
2100/// always asked for json and the junit/text reports are rendered
2101/// client-side, because the route's own junit/text answers stay HTTP
2102/// 200 and carry no counts — a passthrough would exit 0 on a red
2103/// suite.
2104#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
2105pub enum TestingFormatArg {
2106    /// Structured results only (default) — `data.report` stays empty
2107    #[default]
2108    Json,
2109    /// JUnit XML in `data.report`, for a CI test reporter
2110    Junit,
2111    /// Console-style text in `data.report`, for a human reading logs
2112    Text,
2113}
2114
2115impl From<TestingFormatArg> for ignition_core::actions::testing::TestingFormat {
2116    fn from(value: TestingFormatArg) -> Self {
2117        match value {
2118            TestingFormatArg::Json => Self::Json,
2119            TestingFormatArg::Junit => Self::Junit,
2120            TestingFormatArg::Text => Self::Text,
2121        }
2122    }
2123}
2124
2125#[derive(Debug, Subcommand)]
2126pub enum TestingCommand {
2127    /// Run the gateway-side Jython test suite and return the machine
2128    /// verdict (exit 0 green, exit 6 red with the FULL results still
2129    /// in the envelope)
2130    Run {
2131        /// The project whose testing bundle runs. REQUIRED — the
2132        /// bundle deploys per project and running a suite executes
2133        /// that project's gateway-side code, so there is
2134        /// deliberately no `ign-cli` default here
2135        #[arg(long, value_name = "NAME", required = true)]
2136        project: String,
2137        /// List the gateway's discovered test modules instead of
2138        /// running them
2139        #[arg(long, conflicts_with_all = ["module", "package"])]
2140        discover: bool,
2141        /// Run ONE dotted module (`proj.foo_test`); mutually
2142        /// exclusive with --package and --discover
2143        #[arg(long, value_name = "DOTTED", conflicts_with = "package")]
2144        module: Option<String>,
2145        /// Run every module under a package prefix (`proj.`);
2146        /// mutually exclusive with --module and --discover
2147        #[arg(long, value_name = "PREFIX")]
2148        package: Option<String>,
2149        /// Report rendered into `data.report`. The verdict and exit
2150        /// code are IDENTICAL in every format — the structured
2151        /// results always ride under `data.results`
2152        #[arg(long, value_name = "FORMAT", default_value = "json")]
2153        format: TestingFormatArg,
2154    },
2155}
2156
2157/// `ign lint` args (07-04, INTR-02) — the ignition-lint delegation:
2158/// PATHS map to `--target <path>` pairs on the child's arg vector;
2159/// `--` passthrough args ride verbatim after them (power users:
2160/// `--profile`, `--checks`, `--fail-on`, …).
2161#[derive(Debug, clap::Args)]
2162pub struct LintArgs {
2163    /// Paths to lint (one --target pair each)
2164    #[arg(value_name = "PATH", required = true)]
2165    pub paths: Vec<String>,
2166    /// Exit with the linter's own exit code (CI mode). Default is
2167    /// the doctor posture: findings + child_exit_code + the parsed
2168    /// report ride as data and the command exits 0 whenever the
2169    /// tool RAN
2170    #[arg(long)]
2171    pub strict: bool,
2172    /// Extra args passed to ignition-lint verbatim (after --)
2173    #[arg(last = true, value_name = "ARGS")]
2174    pub passthrough: Vec<String>,
2175}
2176
2177#[derive(Debug, Subcommand)]
2178pub enum ScriptCommand {
2179    /// Execute gateway-side Python (Jython) — non-interactive, the
2180    /// route's entire purpose
2181    Run {
2182        /// Inline Python source (a one-liner's best form)
2183        #[arg(long, value_name = "PY")]
2184        code: Option<String>,
2185        /// Python source file (`-` reads stdin — the agent pipe
2186        /// path); giving both --code and --file refuses
2187        /// `invalid_input` (exit 2) pre-resolution
2188        #[arg(long, value_name = "PATH")]
2189        file: Option<String>,
2190        /// The deployed routes' project (default ign-cli — where
2191        /// `ign webdev deploy` put scriptExec)
2192        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
2193        project: String,
2194    },
2195}
2196
2197/// `ign api` args (09-03, EXT-01) — the raw-passthrough family. One
2198/// verb today (`call`); the escape hatch for the 80+ gateway endpoint
2199/// families no curated command covers.
2200#[derive(Debug, clap::Args)]
2201pub struct ApiArgs {
2202    #[command(subcommand)]
2203    pub command: ApiCommand,
2204}
2205
2206#[derive(Debug, Subcommand)]
2207pub enum ApiCommand {
2208    /// Call any gateway REST endpoint raw: `--method`/`--path` are the
2209    /// request, `--data`/`--header`/`--query` shape it, and the
2210    /// envelope's `data.result.data` is the gateway's JSON VERBATIM
2211    /// (no field dropped, no value coerced, key order preserved — the
2212    /// README's documented contract exception). Auth-pattern headers
2213    /// (`Authorization`, `X-Ignition-API-Token`, `Cookie`) are
2214    /// refused pre-I/O — credentials come from the profile.
2215    Call(ApiCallArgs),
2216}
2217
2218/// `ign api call` args (09-03, EXT-01). Arbitrary verbs accepted —
2219/// reqwest's `Method` parser validates (usage-class refusal for
2220/// garbage); `--data` is RAW TEXT on ANY method (GET/DELETE bodies
2221/// allowed — curl parity; the gateway's answer classifies); ONE query
2222/// mechanism (repeatable `--query k=v`, never `?` in `--path`).
2223#[derive(Debug, clap::Args)]
2224pub struct ApiCallArgs {
2225    /// HTTP method (GET, POST, PUT, DELETE, PATCH, HEAD, …)
2226    #[arg(long, value_name = "METHOD")]
2227    pub method: String,
2228    /// Absolute path on the gateway (must start with `/`; no `?`, no host)
2229    #[arg(long, value_name = "PATH")]
2230    pub path: String,
2231    /// Raw body text passthrough (rides ANY method — curl parity)
2232    #[arg(long, value_name = "TEXT")]
2233    pub data: Option<String>,
2234    /// Extra request header `Name: Value` (repeatable; split on the
2235    /// FIRST `:`; auth-pattern names are refused pre-I/O)
2236    #[arg(long, value_name = "NAME: VALUE")]
2237    pub header: Vec<String>,
2238    /// Query pair `k=v` (repeatable; split on the FIRST `=` — the
2239    /// ONE query mechanism)
2240    #[arg(long, value_name = "K=V")]
2241    pub query: Vec<String>,
2242}
2243
2244/// `ign license` args (09-04, EXT-02) — the license morning-check
2245/// family. One verb today; room to grow (the uniform-leaf shape).
2246#[derive(Debug, clap::Args)]
2247pub struct LicenseArgs {
2248    #[command(subcommand)]
2249    pub command: LicenseCommand,
2250}
2251
2252#[derive(Debug, Subcommand)]
2253pub enum LicenseCommand {
2254    /// License inventory + trial state in ONE command: the
2255    /// hardware-key item rows and effective stamp ride the
2256    /// `/licenses` read, the license mode + countdown ride the trial
2257    /// companion (the mode is NOT on the licenses payload — capture
2258    /// fact)
2259    Status,
2260}
2261
2262/// `ign redundancy` args (09-04, EXT-02) — the redundancy
2263/// morning-check family.
2264#[derive(Debug, clap::Args)]
2265pub struct RedundancyArgs {
2266    #[command(subcommand)]
2267    pub command: RedundancyCommand,
2268}
2269
2270#[derive(Debug, Subcommand)]
2271pub enum RedundancyCommand {
2272    /// The flat redundancy status: role, project state, peer
2273    /// connection, config access, sync/failover pending, uptime
2274    /// (ms since gateway start — capture-proven) and last-sync (the
2275    /// `-1` never-synced sentinel on fresh rigs)
2276    Status {
2277        /// Also read a second node through PROFILE and name the active
2278        /// node (activity level decides, never role)
2279        #[arg(long, value_name = "PROFILE")]
2280        peer: Option<String>,
2281    },
2282}
2283
2284/// `ign gan` args (09-04, EXT-02) — the Gateway Area Network
2285/// morning-check family.
2286#[derive(Debug, clap::Args)]
2287pub struct GanArgs {
2288    #[command(subcommand)]
2289    pub command: GanCommand,
2290}
2291
2292#[derive(Debug, Subcommand)]
2293pub enum GanCommand {
2294    /// The GAN overview: total/running connections, in/out byte
2295    /// rates, remote gateways — all zeros are healthy data on a
2296    /// non-GAN gateway (the capture IS the canonical shape)
2297    Status,
2298}
2299
2300/// `ign diagnostics` args (09-05, EXT-02) — the support-bundle
2301/// family: `bundle` nests one level (the `tags provider` grouped-
2302/// subfamily pattern).
2303#[derive(Debug, clap::Args)]
2304pub struct DiagnosticsArgs {
2305    #[command(subcommand)]
2306    pub command: DiagnosticsCommand,
2307}
2308
2309#[derive(Debug, Subcommand)]
2310pub enum DiagnosticsCommand {
2311    /// Support-bundle operations (generate / status / download /
2312    /// wait) — the state machine is ENCODED FROM LIVE CAPTURES:
2313    /// observed states are exactly `Generating` (mid-generation) and
2314    /// `Valid` (terminal/ready), PascalCase, both rigs
2315    #[command(subcommand)]
2316    Bundle(BundleCommand),
2317}
2318
2319/// The four bundle verbs (09-05). Nothing here is destructive:
2320/// generate creates a support bundle, download writes a local file,
2321/// wait polls — none carry `--yes` (the backup-download posture).
2322#[derive(Debug, Subcommand)]
2323pub enum BundleCommand {
2324    /// Start bundle generation (POST, no body) — the 200 answer IS
2325    /// the fresh status (live capture: `{"state":"Generating"}`);
2326    /// generation takes ~2–6 s on a fresh rig, so pair with `wait`
2327    Generate,
2328    /// The bundle status: `state` rides the CAPTURED vocabulary
2329    /// (`Generating` / `Valid` — unknown future states pass through
2330    /// verbatim, never refused); `fileSize` (bytes) appears only
2331    /// when `Valid`
2332    Status,
2333    /// Download the bundle ZIP (streamed to disk; the request rides
2334    /// a 300 s per-request timeout — the 30 s client default would
2335    /// truncate MB-sized bundles)
2336    Download {
2337        /// Output file (default: the gateway's Content-Disposition
2338        /// name, else ignition-diagnostics-bundle-<unix_ts>.zip)
2339        #[arg(short, long, value_name = "FILE")]
2340        output: Option<PathBuf>,
2341    },
2342    /// Poll the status until a terminal captured state (`Valid`)
2343    /// — an UNKNOWN state (outside the captured vocabulary) keeps
2344    /// polling honestly until the deadline; deadline expiry is
2345    /// exit 4 `network_error` (the restart-wait convention, no new
2346    /// slug) with the last observed state in the message
2347    Wait {
2348        /// Poll interval in seconds
2349        #[arg(long, default_value_t = 2, value_name = "SECS")]
2350        interval: u64,
2351        /// Give up after this many seconds (default 300 — mirrors
2352        /// restart --wait)
2353        #[arg(long, default_value_t = 300, value_name = "SECS")]
2354        timeout: u64,
2355    },
2356}
2357
2358/// `ign jvm` args — the SPECIAL-MODE profiling family. Every leaf
2359/// carries `--project` (the deployed scriptExec route's home, the
2360/// `script run` default) and the intrusive leaves carry the family's
2361/// own gate flag: `--i-accept-the-risk` is deliberately NOT the
2362/// global `--yes` — a generic "confirm destructive ops" blanket must
2363/// never silently cover ACTIVE JVM instrumentation.
2364#[derive(Debug, clap::Args)]
2365pub struct JvmArgs {
2366    #[command(subcommand)]
2367    pub command: JvmCommand,
2368}
2369
2370#[derive(Debug, Subcommand)]
2371pub enum JvmCommand {
2372    /// Sample every JVM thread's stack for a capped window and rank
2373    /// the hottest methods and stacks (the folded output is
2374    /// flamegraph-ready). ACTIVE instrumentation: CPU overhead grows
2375    /// with thread count x frequency — refused without
2376    /// --i-accept-the-risk; duration is HARD-CAPPED at 120s
2377    Sample {
2378        /// Seconds to sample per window (default 30 — 10 with
2379        /// --follow; 5–120, the cap is not configurable)
2380        #[arg(long, value_name = "SECS")]
2381        duration_secs: Option<u64>,
2382        /// Milliseconds between thread dumps (default 20 — 50 with
2383        /// --follow; 5–1000 — below 5ms the sampler thread itself
2384        /// skews the JVM)
2385        #[arg(long, value_name = "MS")]
2386        interval_ms: Option<u64>,
2387        /// Hot-method rows to report (default 25; cap 100)
2388        #[arg(long, default_value_t = 25, value_name = "N")]
2389        top: u64,
2390        /// Also write the folded stacks to this file (flamegraph.pl /
2391        /// speedscope input; one-shot only — refused with --follow)
2392        #[arg(long, value_name = "PATH")]
2393        folded_out: Option<PathBuf>,
2394        /// CONTINUOUS MODE: repeat bounded sampling windows
2395        /// back-to-back, streaming each window's rollup as it lands
2396        /// (NDJSON envelopes under --json/--compact, the `logs -f`
2397        /// precedent), until Ctrl-C. Continuous sampling IS
2398        /// continuous gateway overhead — watch it, then stop it
2399        #[arg(long, action = ArgAction::SetTrue)]
2400        follow: bool,
2401        /// REQUIRED acknowledgment that this actively instruments the
2402        /// gateway JVM and must not run on production casually —
2403        /// --yes does NOT satisfy this gate
2404        #[arg(long, action = ArgAction::SetTrue)]
2405        i_accept_the_risk: bool,
2406        /// The deployed routes' project (default ign-cli — where
2407        /// `ign webdev deploy` put scriptExec)
2408        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
2409        project: String,
2410    },
2411    /// Java Flight Recorder operations — `start` is gated active
2412    /// instrumentation; `status` and `stop` are ungated (checking and
2413    /// cleaning up must always be cheap to reach)
2414    #[command(arg_required_else_help = true)]
2415    Jfr(JvmJfrArgs),
2416}
2417
2418/// `ign jvm jfr` args (the `tags provider` grouped-subfamily pattern).
2419#[derive(Debug, clap::Args)]
2420pub struct JvmJfrArgs {
2421    #[command(subcommand)]
2422    pub command: JvmJfrCommand,
2423}
2424
2425#[derive(Debug, Subcommand)]
2426pub enum JvmJfrCommand {
2427    /// Start a flight recording named `ign-cli` — memory-buffered
2428    /// (nothing touches gateway disk until `stop` dumps) and
2429    /// duration-capped so it auto-stops even if you forget. Refused
2430    /// without --i-accept-the-risk; duration HARD-CAPPED at 300s
2431    Start {
2432        /// Recording duration in seconds — the auto-stop deadline
2433        /// (default 60; 10–300, the cap is not configurable)
2434        #[arg(long, default_value_t = 60, value_name = "SECS")]
2435        duration_secs: u64,
2436        /// The JDK's bundled settings: `default` (lower overhead) or
2437        /// `profile` (deeper, heavier)
2438        #[arg(long, value_enum, default_value_t = JfrSettingsArg::Default, value_name = "NAME")]
2439        settings: JfrSettingsArg,
2440        /// REQUIRED acknowledgment that this actively instruments the
2441        /// gateway JVM and must not run on production casually —
2442        /// --yes does NOT satisfy this gate
2443        #[arg(long, action = ArgAction::SetTrue)]
2444        i_accept_the_risk: bool,
2445        /// The deployed routes' project (default ign-cli)
2446        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
2447        project: String,
2448    },
2449    /// List the JVM's flight recordings verbatim (READ-only — not
2450    /// gated; this is how you confirm a recording is really gone)
2451    Status {
2452        /// The deployed routes' project (default ign-cli)
2453        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
2454        project: String,
2455    },
2456    /// Stop the `ign-cli` recording, dump it, fetch the .jfr, and
2457    /// delete the gateway-side file (cleanup — NOT gated: stopping
2458    /// reduces risk and must never be refused). Dumps over 32 MiB
2459    /// are left on the gateway with the remote path reported
2460    Stop {
2461        /// Local output file (default: ign-cli-<unix_ts>.jfr)
2462        #[arg(short, long, value_name = "FILE")]
2463        output: Option<PathBuf>,
2464        /// The deployed routes' project (default ign-cli)
2465        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
2466        project: String,
2467    },
2468}
2469
2470/// The JFR settings whitelist as a clap ValueEnum (free text never
2471/// reaches the MBean invocation).
2472#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
2473pub enum JfrSettingsArg {
2474    /// `settings=default` — the lower-overhead bundled config
2475    Default,
2476    /// `settings=profile` — the deeper, heavier bundled config
2477    Profile,
2478}
2479
2480/// Profile subcommands (nested: a struct wrapper carrying the subcommand
2481/// enum, so `Commands::Profile` gets an `Args` payload).
2482#[derive(Debug, clap::Args)]
2483pub struct ProfileArgs {
2484    #[command(subcommand)]
2485    pub command: ProfileCmd,
2486}
2487
2488/// Sessions args: the family filter rides the TOP level so bare
2489/// `ign sessions [--type X]` lists (must-have truth #1) while
2490/// `ign sessions terminate …` carries the destructive half.
2491#[derive(Debug, clap::Args)]
2492pub struct SessionsArgs {
2493    /// Filter to one session family (default: all three merged)
2494    #[arg(long, value_enum)]
2495    pub r#type: Option<SessionType>,
2496
2497    #[command(subcommand)]
2498    pub command: Option<SessionsCmd>,
2499}
2500
2501#[derive(Debug, Subcommand)]
2502pub enum SessionsCmd {
2503    /// Terminate (designer: prune / vision: close) a session —
2504    /// destructive, refused without --yes
2505    Terminate {
2506        /// Session family holding the id
2507        #[arg(long, value_enum)]
2508        r#type: SessionType,
2509        /// Session/client id to terminate (see `ign sessions`)
2510        #[arg(long)]
2511        id: String,
2512        /// Message shown to the session's user (Perspective only)
2513        #[arg(long)]
2514        message: Option<String>,
2515    },
2516}
2517
2518/// Logs args: the query filters ride the TOP level so bare
2519/// `ign logs [-f]` lists/tails (must-have truth #1) while `download`
2520/// and the `loggers` subtree hang off the optional subcommand — the
2521/// SessionsArgs precedent.
2522#[derive(Debug, clap::Args)]
2523pub struct LogsArgs {
2524    /// Only entries from this logger (name prefix)
2525    #[arg(long, value_name = "NAME")]
2526    pub logger: Option<String>,
2527    /// Minimum level to include (server-side filter)
2528    #[arg(long, value_enum, value_name = "LEVEL")]
2529    pub min_level: Option<LogLevel>,
2530    /// Start from an absolute EPOCH-MS or a relative span (500ms, 30s,
2531    /// 5min, 2h) — parsed to epoch-ms at arg-parse time
2532    #[arg(
2533        long,
2534        value_name = "EPOCH_MS|Nms|Ns|Nmin|Nh",
2535        value_parser = parse_since_arg
2536    )]
2537    pub since: Option<i64>,
2538    /// Max entries — the server default is UNLIMITED, never used here
2539    #[arg(long, default_value_t = 200)]
2540    pub limit: i64,
2541    /// Follow: stream new entries as they occur (poll-based)
2542    #[arg(short = 'f', long)]
2543    pub follow: bool,
2544    /// Poll interval in seconds (follow mode)
2545    #[arg(long, default_value_t = 2, value_name = "SECS")]
2546    pub interval: u64,
2547    /// Stop after this many seconds (follow mode; default: until Ctrl-C)
2548    #[arg(long, value_name = "SECS")]
2549    pub timeout: Option<u64>,
2550    #[command(subcommand)]
2551    pub command: Option<LogsCmd>,
2552}
2553
2554#[derive(Debug, Subcommand)]
2555pub enum LogsCmd {
2556    /// Download the log archive — a SQLite .idb, never a zip
2557    Download {
2558        /// Output file (default: the gateway's Content-Disposition
2559        /// name, else <profile>-logs-<ts>.idb)
2560        #[arg(short, long, value_name = "FILE")]
2561        output: Option<PathBuf>,
2562    },
2563    /// List loggers / manage logger levels
2564    Loggers(LoggersArgs),
2565}
2566
2567#[derive(Debug, clap::Args)]
2568pub struct LoggersArgs {
2569    /// Substring search over logger names
2570    #[arg(long, value_name = "TEXT")]
2571    pub search: Option<String>,
2572    #[command(subcommand)]
2573    pub command: Option<LoggersCmd>,
2574}
2575
2576#[derive(Debug, Subcommand)]
2577pub enum LoggersCmd {
2578    /// Set one logger's level — a mutation, refused without --yes
2579    Set {
2580        /// Logger name (see `ign logs loggers`)
2581        name: String,
2582        /// Level to set
2583        #[arg(value_enum)]
2584        level: LogLevel,
2585    },
2586    /// Reset ALL logger levels to defaults — refused without --yes
2587    Reset,
2588}
2589
2590/// The seven spec-documented log levels (TRACE..OFF), value-enum form
2591/// for clap; [`LogLevel::wire`] yields the uppercase wire token.
2592#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
2593pub enum LogLevel {
2594    Trace,
2595    Debug,
2596    Info,
2597    Warn,
2598    Error,
2599    Fatal,
2600    Off,
2601}
2602
2603impl LogLevel {
2604    /// The uppercase wire token the gateway expects.
2605    pub fn wire(self) -> &'static str {
2606        match self {
2607            Self::Trace => "TRACE",
2608            Self::Debug => "DEBUG",
2609            Self::Info => "INFO",
2610            Self::Warn => "WARN",
2611            Self::Error => "ERROR",
2612            Self::Fatal => "FATAL",
2613            Self::Off => "OFF",
2614        }
2615    }
2616}
2617
2618/// clap value parser delegating to the core `--since` grammar against
2619/// the current time (a relative span resolves at parse time) — invalid
2620/// specs are clap usage errors (exit 2) like any bad flag value.
2621fn parse_since_arg(spec: &str) -> Result<i64, String> {
2622    let now_ms = std::time::SystemTime::now()
2623        .duration_since(std::time::UNIX_EPOCH)
2624        .expect("system clock is after the unix epoch")
2625        .as_millis() as i64;
2626    ignition_core::actions::logs::parse_since(spec, now_ms)
2627}
2628
2629/// CLI value-enum mirrors of the core action enums (ignition-core stays
2630/// clap-free; `From` converts at the dispatch seam).
2631#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
2632pub enum SessionType {
2633    /// Designer sessions
2634    Designer,
2635    /// Perspective browser sessions
2636    Perspective,
2637    /// Vision clients
2638    Vision,
2639}
2640
2641impl From<SessionType> for ignition_core::actions::sessions::SessionType {
2642    fn from(value: SessionType) -> Self {
2643        match value {
2644            SessionType::Designer => Self::Designer,
2645            SessionType::Perspective => Self::Perspective,
2646            SessionType::Vision => Self::Vision,
2647        }
2648    }
2649}
2650
2651#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
2652pub enum ConnectionType {
2653    /// Database connections
2654    Database,
2655    /// OPC connections
2656    Opc,
2657}
2658
2659impl From<ConnectionType> for ignition_core::actions::connections::ConnectionType {
2660    fn from(value: ConnectionType) -> Self {
2661        match value {
2662            ConnectionType::Database => Self::Database,
2663            ConnectionType::Opc => Self::Opc,
2664        }
2665    }
2666}
2667
2668/// Import collision policy (03-02). Exactly the two values REST
2669/// exposes — `merge` is the Designer import popup's vocabulary and is
2670/// NOT a value: clap's invalid-value error lists the two real
2671/// choices, and the README documents merge as Designer-only (the
2672/// sanctioned rejection-with-hint shape).
2673#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
2674pub enum CollisionPolicy {
2675    /// Refuse when the project already exists (default)
2676    Abort,
2677    /// Replace the ENTIRE project (resources absent from the ZIP are
2678    /// deleted) — destructive, requires --yes
2679    Overwrite,
2680}
2681
2682impl From<CollisionPolicy> for ignition_core::actions::projects::CollisionPolicy {
2683    fn from(value: CollisionPolicy) -> Self {
2684        match value {
2685            CollisionPolicy::Abort => Self::Abort,
2686            CollisionPolicy::Overwrite => Self::Overwrite,
2687        }
2688    }
2689}
2690
2691#[derive(Debug, Subcommand)]
2692#[command(arg_required_else_help = true)]
2693pub enum ProfileCmd {
2694    /// Add (or overwrite) a gateway profile
2695    Add {
2696        /// Profile name
2697        name: String,
2698        /// Gateway base URL (e.g. http://localhost:9088)
2699        url: String,
2700        /// Optional display label
2701        #[arg(long, value_name = "TEXT")]
2702        label: Option<String>,
2703        /// Name of the env var holding the auth token
2704        #[arg(long, value_name = "VAR")]
2705        token_env: Option<String>,
2706        /// Keyring user string for the token (service is always ignition-cli)
2707        #[arg(long, value_name = "USER")]
2708        keyring: Option<String>,
2709        /// Name of the env var holding the basic-auth user (with --password-env)
2710        #[arg(long, value_name = "VAR")]
2711        user_env: Option<String>,
2712        /// Name of the env var holding the basic-auth password (with --user-env)
2713        #[arg(long, value_name = "VAR")]
2714        password_env: Option<String>,
2715        /// Make this profile the active one
2716        #[arg(long)]
2717        active: bool,
2718    },
2719    /// List configured profiles
2720    List,
2721    /// Switch the active profile
2722    Use {
2723        /// Profile name to activate
2724        name: String,
2725    },
2726}