Skip to main content

ignition_cli/
cli.rs

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