Skip to main content

ignition_cli/
cli.rs

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