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