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    /// Manage gateway projects: list with inheritance info, new, copy,
124    /// rename, set (reparent), delete, export/import (ZIP)
125    #[command(arg_required_else_help = true)]
126    Project(ProjectArgs),
127
128    /// Manage a project's individual resources: list, get, put,
129    /// delete — the surgical edit loop (change one view/script
130    /// without re-importing everything)
131    #[command(arg_required_else_help = true)]
132    Resource(ResourceArgs),
133
134    /// Manage the CLI's own WebDev routes on the gateway: deploy the
135    /// embedded bundle, verify the version handshake
136    #[command(arg_required_else_help = true)]
137    Webdev(WebdevArgs),
138
139    /// Tag operations: manage providers (native REST) and browse /
140    /// read / write tag values (the deployed WebDev routes — run
141    /// `ign webdev deploy` first)
142    #[command(arg_required_else_help = true)]
143    Tags(TagsArgs),
144
145    /// Manage a Docker compose rig: up/down/reset/status/logs/trial
146    /// (snapshot arrives in a later plan) — docker-only, no profile
147    /// needed
148    #[command(arg_required_else_help = true)]
149    Rig(RigArgs),
150
151    /// Manage gateway backups (gwbk) on any profiled gateway:
152    /// download (streamed) and restore (guarded)
153    #[command(arg_required_else_help = true)]
154    Backup(BackupArgs),
155
156    /// EAM task orchestration (Enterprise Administration Module):
157    /// history/definitions reads, guarded task create + force —
158    /// every verb honestly reports the controller-mode state gate
159    #[command(arg_required_else_help = true)]
160    Eam(EamArgs),
161
162    /// Run gateway-side Python (Jython) through the secret-gated
163    /// scriptExec route — the opt-in is STRUCTURAL (`ign webdev
164    /// deploy --with-script-exec` deploys the route + persists its
165    /// secret); there is no --yes on this verb by design
166    #[command(arg_required_else_help = true)]
167    Script(ScriptArgs),
168
169    /// Lint local project files by delegating to ignition-lint (PATH
170    /// discovery) — doctor posture: findings are DATA, exit 0
171    /// whenever the tool ran; --strict passes the tool's exit code
172    /// through for CI
173    Lint(LintArgs),
174
175    /// Manage gateway profiles
176    #[command(arg_required_else_help = true)]
177    Profile(ProfileArgs),
178
179    /// Interactive TUI cockpit
180    #[cfg(feature = "tui")]
181    Tui,
182}
183
184/// Wait targets (02-05, HLTH-11). `gateway` and `restart` poll the
185/// UNAUTHENTICATED /StatusPing (the dispatch builds a header-less
186/// client for them — waiting must work when auth is broken); `module`
187/// is an authed read.
188#[derive(Debug, clap::Args)]
189pub struct WaitArgs {
190    #[command(subcommand)]
191    pub command: WaitCmd,
192}
193
194#[derive(Debug, Subcommand)]
195pub enum WaitCmd {
196    /// Wait until the gateway reports RUNNING (unauthenticated
197    /// StatusPing — works even when auth is broken or absent)
198    Gateway {
199        /// Poll interval in seconds
200        #[arg(long, default_value_t = 2, value_name = "SECS")]
201        interval: u64,
202        /// Give up after this many seconds
203        #[arg(long, default_value_t = 120, value_name = "SECS")]
204        timeout: u64,
205    },
206    /// Wait for a restart to complete — restart-aware: shares
207    /// `restart --wait`'s semantics (non-RUNNING observed once →
208    /// RUNNING; a 5 s floor guards the all-RUNNING case)
209    Restart {
210        /// Poll interval in seconds
211        #[arg(long, default_value_t = 2, value_name = "SECS")]
212        interval: u64,
213        /// Give up after this many seconds (default 300)
214        #[arg(long, default_value_t = 300, value_name = "SECS")]
215        timeout: u64,
216    },
217    /// Wait until a module reports ACTIVE
218    Module {
219        /// Module id (see `ign modules`)
220        id: String,
221        /// Poll interval in seconds
222        #[arg(long, default_value_t = 2, value_name = "SECS")]
223        interval: u64,
224        /// Give up after this many seconds
225        #[arg(long, default_value_t = 120, value_name = "SECS")]
226        timeout: u64,
227    },
228}
229
230/// Project subcommands (03-01, PROJ-01/02). `delete` is the family's
231/// ONE destructive verb (`--yes`-guarded, exit 2 without); copy/
232/// rename/set create or relabel — never destroy — so they carry NO
233/// guard (planner decision per research).
234#[derive(Debug, clap::Args)]
235pub struct ProjectArgs {
236    #[command(subcommand)]
237    pub command: ProjectCommand,
238}
239
240#[derive(Debug, Subcommand)]
241pub enum ProjectCommand {
242    /// List every runnable project: name, title, enabled, parent,
243    /// inheritable (inheritance info from the items themselves)
244    List,
245    /// Create a project (only provided fields ride the create body)
246    New {
247        /// Project name
248        name: String,
249        /// Display title
250        #[arg(long, value_name = "TEXT")]
251        title: Option<String>,
252        /// Long description
253        #[arg(long, value_name = "TEXT")]
254        description: Option<String>,
255        /// Parent project (inheritance)
256        #[arg(long, value_name = "NAME")]
257        parent: Option<String>,
258        /// Mark this project eligible as a parent
259        #[arg(long)]
260        inheritable: bool,
261        /// Create the project disabled
262        #[arg(long)]
263        disabled: bool,
264    },
265    /// Copy a project with all its resources
266    Copy {
267        /// Source project name
268        src: String,
269        /// Destination name (must not exist)
270        dst: String,
271    },
272    /// Rename a project (native rename, not copy+delete)
273    Rename {
274        /// Current name
275        old_name: String,
276        /// New name
277        new_name: String,
278    },
279    /// Set project fields — --parent IS the inheritance move (reparent)
280    #[command(group(
281        clap::ArgGroup::new("set_fields")
282            .required(true)
283            .multiple(true)
284            .args(&["title", "description", "parent", "set_enabled", "disabled", "inheritable"])
285    ))]
286    Set {
287        /// Project name
288        name: String,
289        /// Display title
290        #[arg(long, value_name = "TEXT")]
291        title: Option<String>,
292        /// Long description
293        #[arg(long, value_name = "TEXT")]
294        description: Option<String>,
295        /// Parent project (reparent)
296        #[arg(long, value_name = "NAME")]
297        parent: Option<String>,
298        /// Enable the project
299        #[arg(long, conflicts_with = "disabled")]
300        set_enabled: bool,
301        /// Disable the project
302        #[arg(long)]
303        disabled: bool,
304        /// Whether this project may serve as a parent (true/false)
305        #[arg(long, value_name = "BOOL")]
306        inheritable: Option<bool>,
307    },
308    /// Delete a project — destructive, refused without --yes
309    Delete {
310        /// Project name
311        name: String,
312    },
313    /// Export a project as a ZIP archive (streams to disk)
314    Export {
315        /// Project name
316        name: String,
317        /// Output file (default: the gateway's Content-Disposition
318        /// name, else <name>.zip); with --decode-scripts, the output
319        /// DIRECTORY (default <name>-export/)
320        #[arg(short, long, value_name = "FILE")]
321        output: Option<PathBuf>,
322        /// Also decode embedded JSON scripts (Perspective view.json
323        /// etc.) into editable `<member>.<n>.py` sidecars beside a
324        /// copied member tree + scripts-manifest.json — the output is
325        /// a DIRECTORY (script-python members are already plain .py
326        /// and never decode; expressions pass through)
327        #[arg(long)]
328        decode_scripts: bool,
329    },
330    /// Import a project from a ZIP archive
331    Import {
332        /// Project name to import as
333        name: String,
334        /// ZIP file path, or - to read the archive from stdin; with
335        /// --encode-scripts, a decoded export DIRECTORY (re-zipped
336        /// with the sidecars spliced back before upload; stdin is
337        /// invalid in that mode)
338        #[arg(long, value_name = "PATH")]
339        file: String,
340        /// Collision policy: abort refuses when the name exists
341        /// (default); overwrite REPLACES the entire project —
342        /// destructive, requires --yes. merge is Designer-only (not a
343        /// value; the README documents why)
344        #[arg(long, value_enum, default_value_t = CollisionPolicy::Abort)]
345        collision_policy: CollisionPolicy,
346        /// --file points at a decoded export DIRECTORY (from
347        /// `export --decode-scripts`): the sidecars are spliced back
348        /// and the manifest stripped before the standard import path
349        #[arg(long)]
350        encode_scripts: bool,
351    },
352    /// Compare a project across two gateway profiles — statuses are
353    /// B-relative-to-A (`added` = in B only, `removed` = in A only,
354    /// `changed` = differing after resource.json normalization)
355    Diff {
356        /// Baseline profile (A)
357        profile_a: String,
358        /// Compared profile (B — the diff is B relative to A)
359        profile_b: String,
360        /// Project name
361        #[arg(long, value_name = "NAME")]
362        project: String,
363    },
364    /// Promote selected resources from profile A into profile B
365    /// (direction is ALWAYS A→B) — destructive on B: the whole
366    /// project is overwrite-imported, refused without --yes
367    Sync {
368        /// Source profile (A)
369        profile_a: String,
370        /// Target profile (B)
371        profile_b: String,
372        /// Project name
373        #[arg(long, value_name = "NAME")]
374        project: String,
375        /// Resource user path to promote (repeatable)
376        #[arg(long, value_name = "PATH")]
377        resource: Vec<String>,
378        /// Promote every resource the diff reports added or changed
379        /// (never removed ones — deletion needs --delete)
380        #[arg(long)]
381        all_changed: bool,
382        /// Also remove B's resources the diff reports removed
383        /// (default: upsert-only, nothing is ever deleted)
384        #[arg(long)]
385        delete: bool,
386    },
387}
388
389/// Resource subcommands (03-03, PROJ-05). `delete` is the family's
390/// destructive verb (`--yes`-guarded, exit 2 without — the
391/// sessions-terminate shape); `put` is an upsert (create-or-replace
392/// ONE resource with explicit content) and stays friction-free per
393/// the planner decision — agents pass exactly what they want written.
394#[derive(Debug, clap::Args)]
395pub struct ResourceArgs {
396    #[command(subcommand)]
397    pub command: ResourceCommand,
398}
399
400#[derive(Debug, Subcommand)]
401pub enum ResourceCommand {
402    /// List a project's resources (one path per line in human mode)
403    List {
404        /// Project name
405        project: String,
406        /// Only paths under this prefix (rides the wire as the
407        /// server-side `path` filter)
408        #[arg(long, value_name = "PREFIX")]
409        prefix: Option<String>,
410    },
411    /// Read one resource: JSON pretty-printed, text raw — binary
412    /// (data.bin-class) resources refuse with exit 6
413    Get {
414        /// Project name
415        project: String,
416        /// Resource path, slashes kept (e.g.
417        /// `ignition/script-python/e2e/scratch`)
418        path: String,
419    },
420    /// Write one resource (upsert: created if absent, replaced if
421    /// present) — JSON if parseable (application/json), else UTF-8
422    /// text (text/plain); binary-looking input refuses
423    Put {
424        /// Project name
425        project: String,
426        /// Resource path
427        path: String,
428        /// File to read the content from, or `-` for stdin
429        #[arg(long, value_name = "PATH")]
430        file: String,
431    },
432    /// Delete one resource — destructive, refused without --yes
433    Delete {
434        /// Project name
435        project: String,
436        /// Resource path
437        path: String,
438    },
439}
440
441/// Webdev subcommands (05-03, WEB-01/02). Deploy is deliberately NOT
442/// `--yes`-guarded: the dedicated project (default `ign-cli`) is
443/// CLI-OWNED — born from the first deploy zip and overwrite-replaced
444/// on every deploy (replace-not-merge is the CONTRACT here; user
445/// projects are never touched — README documents). scriptExec rides
446/// only on explicit request, gated by a deploy-time generated shared
447/// secret stored in the profile config at 0600.
448#[derive(Debug, clap::Args)]
449pub struct WebdevArgs {
450    #[command(subcommand)]
451    pub command: WebdevCommand,
452}
453
454#[derive(Debug, Subcommand)]
455pub enum WebdevCommand {
456    /// Deploy the embedded route bundle into the dedicated project
457    /// (overwrite-replace — the CLI owns that project wholesale)
458    Deploy {
459        /// Target project (default ign-cli; the CLI owns it wholesale)
460        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
461        project: String,
462        /// Also deploy the secret-gated scriptExec route (a fresh
463        /// secret is generated and stored at 0600 when none exists)
464        #[arg(long)]
465        with_script_exec: bool,
466        /// Generate a FRESH scriptExec secret before deploying (any
467        /// route deployed with the old secret starts refusing)
468        #[arg(long, requires = "with_script_exec")]
469        rotate_secret: bool,
470    },
471    /// Probe every route's version handshake — a READ: exit 0
472    /// whenever the sweep completes, per-route degradation is data
473    Status {
474        /// Target project (default ign-cli)
475        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
476        project: String,
477    },
478}
479
480/// Tags args (05-04, TAGS-01..04) — the grouped-subfamily pattern:
481/// `tags provider …` nests one level deeper (the family's native
482/// REST half), browse/read/write ride the TOP level. The
483/// webdev-dependent arms carry `--project` (default `ign-cli`, the
484/// same default as deploy — the deployed routes live in that
485/// project).
486#[derive(Debug, clap::Args)]
487pub struct TagsArgs {
488    #[command(subcommand)]
489    pub command: TagsCommand,
490}
491
492#[derive(Debug, Subcommand)]
493pub enum TagsCommand {
494    /// Manage tag providers (native config-resource REST — no
495    /// deployed routes involved): list with tag counts + health,
496    /// create a STANDARD provider, delete (guarded)
497    #[command(subcommand)]
498    Provider(TagsProviderCommand),
499
500    /// Browse tags as a tree (Property children filtered by
501    /// default) — providers appear at the root; needs the deployed
502    /// routes (`ign webdev deploy`) — or run fully OFFLINE against
503    /// an export via --from-export
504    Browse {
505        /// Tag path to browse from (default: the root — providers)
506        path: Option<String>,
507        /// Case-insensitive substring filter on name and full path
508        #[arg(long, value_name = "SUBSTR")]
509        filter: Option<String>,
510        /// Include Property children (filtered out by default)
511        #[arg(long)]
512        include_properties: bool,
513        /// Project holding the deployed routes (default ign-cli)
514        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
515        project: String,
516        /// Browse an OFFLINE tag export instead of the gateway (no
517        /// profile, no credential, no routes): the CLI's own `tags
518        /// export` JSON, a legacy `<provider>.json` whole tree, or a
519        /// git-module tags/ directory — mutually exclusive with the
520        /// positional browse path
521        #[arg(long, value_name = "PATH", conflicts_with_all = ["path"])]
522        from_export: Option<PathBuf>,
523    },
524    /// Read one or more tag values (quality and timestamp included)
525    /// — needs the deployed routes
526    Read {
527        /// Tag paths to read, e.g. `[default]T1` (one or more)
528        #[arg(value_name = "PATH", required = true)]
529        paths: Vec<String>,
530        /// Project holding the deployed routes (default ign-cli)
531        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
532        project: String,
533    },
534    /// Write a value to a tag — the value is parsed as a JSON
535    /// scalar (number/bool/null); anything unparseable is sent as a
536    /// string; arrays/objects refuse — needs the deployed routes
537    Write {
538        /// Tag path to write, e.g. `[default]T1`
539        path: String,
540        /// Value to write: JSON scalar (42, 1.5, true), else the raw
541        /// text is sent as a string
542        #[arg(long, value_name = "V")]
543        value: String,
544        /// Project holding the deployed routes (default ign-cli)
545        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
546        project: String,
547    },
548    /// Tag CONFIGURATION CRUD (the surgical edit loop — JSON in,
549    /// JSON out): get/create/edit/delete tag configs through the
550    /// deployed tagConfig route; stringified values are re-parsed so
551    /// agents see real JSON
552    #[command(subcommand)]
553    Config(TagsConfigCommand),
554    /// UDT types and definitions (recursive: parameters + nested
555    /// children) — needs the deployed routes
556    #[command(subcommand)]
557    Udt(TagsUdtCommand),
558    /// Alarm operations: active status (with filters), journal
559    /// history, acknowledge — needs the deployed routes
560    #[command(subcommand)]
561    Alarms(TagsAlarmsCommand),
562    /// Tag history queries (historian-backed values) — needs the
563    /// deployed routes; structurally safe on any rig (data requires
564    /// a provisioned historian)
565    #[command(subcommand)]
566    History(TagsHistoryCommand),
567    /// Export tag subtrees to a JSON file (the bulk-transfer half —
568    /// the gateway's native interchange, JSON only)
569    Export {
570        /// Tag paths to export, e.g. `[default]P5` (one or more)
571        #[arg(value_name = "PATH", required = true)]
572        paths: Vec<String>,
573        /// Output file (`-` = stdout — the raw payload, no envelope;
574        /// default: `<last-path-segment>.json` in the cwd)
575        #[arg(short = 'o', long, value_name = "FILE")]
576        output: Option<PathBuf>,
577        /// Project holding the deployed routes (default ign-cli)
578        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
579        project: String,
580    },
581    /// Import a JSON tag export into a target provider — abort
582    /// (default) refuses on collisions; overwrite replaces them
583    /// (destructive: requires --yes)
584    Import {
585        /// The export file to import (`-` = stdin)
586        #[arg(long, value_name = "FILE")]
587        file: PathBuf,
588        /// Target tag provider (must exist — `ign tags provider
589        /// create NAME` first)
590        #[arg(long, value_name = "NAME")]
591        provider: String,
592        /// Collision policy: abort refuses when tags already exist
593        /// (default); overwrite replaces them (destructive:
594        /// requires --yes). merge is Designer-only (not a value)
595        #[arg(long, value_enum, default_value_t = CollisionPolicy::Abort)]
596        collision_policy: CollisionPolicy,
597        /// Project holding the deployed routes (default ign-cli)
598        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
599        project: String,
600    },
601}
602
603/// `tags config …` — the configuration CRUD subfamily (05-05,
604/// TAGS-05): the get→edit-file→write-back surgical loop. Definition
605/// files are the configure shape (README's traps table: tagType
606/// discriminator, nested children, alarms-as-LIST).
607#[derive(Debug, Subcommand)]
608pub enum TagsConfigCommand {
609    /// Get a tag's configuration as (pretty) JSON — stringified
610    /// value/defaultValue sub-dicts re-parsed into real JSON
611    Get {
612        /// Tag path, e.g. `[default]P5/T1`
613        path: String,
614        /// Project holding the deployed routes (default ign-cli)
615        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
616        project: String,
617    },
618    /// Create a tag from a JSON definition file (`-` = stdin) —
619    /// aborts on an existing node (collision policy 'a')
620    Create {
621        /// Tag path to create, e.g. `[default]P5/T1`
622        path: String,
623        /// JSON definition file (`-` = stdin): `{tagType, value,
624        /// alarms, …}` — see the README configure-shape table
625        #[arg(long, value_name = "FILE")]
626        file: PathBuf,
627        /// Project holding the deployed routes (default ign-cli)
628        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
629        project: String,
630    },
631    /// Edit a tag's configuration from a JSON definition file (`-` =
632    /// stdin) — overwrites that single node (collision policy 'o')
633    Edit {
634        /// Tag path to edit, e.g. `[default]P5/T1`
635        path: String,
636        /// JSON definition file (`-` = stdin)
637        #[arg(long, value_name = "FILE")]
638        file: PathBuf,
639        /// Project holding the deployed routes (default ign-cli)
640        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
641        project: String,
642    },
643    /// Delete tag configurations — destructive, refused without
644    /// --yes (the guard fires before ANY resolution: zero network
645    /// work)
646    Delete {
647        /// Tag paths to delete (one or more)
648        #[arg(value_name = "PATH", required = true)]
649        paths: Vec<String>,
650        /// Project holding the deployed routes (default ign-cli)
651        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
652        project: String,
653    },
654}
655
656/// `tags udt …` — the UDT subfamily (05-05, TAGS-06).
657#[derive(Debug, Subcommand)]
658pub enum TagsUdtCommand {
659    /// List the provider's UDT types
660    Types {
661        /// Tag provider whose `_types_` folder to browse (default
662        /// `default`)
663        #[arg(long, default_value = "default", value_name = "NAME")]
664        provider: String,
665        /// Project holding the deployed routes (default ign-cli)
666        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
667        project: String,
668    },
669    /// Get a UDT definition (parameters + nested children, recursive)
670    Def {
671        /// UDT type name, e.g. `Motor`
672        name: String,
673        /// Tag provider (default `default`)
674        #[arg(long, default_value = "default", value_name = "NAME")]
675        provider: String,
676        /// Project holding the deployed routes (default ign-cli)
677        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
678        project: String,
679    },
680}
681
682#[derive(Debug, Subcommand)]
683pub enum TagsProviderCommand {
684    /// List the gateway's tag providers (tag counts + health)
685    List,
686    /// Create a STANDARD tag provider (DB-backed providers are out
687    /// of scope at MVP)
688    Create {
689        /// Provider name to create
690        name: String,
691    },
692    /// Delete a tag provider — destructive, refused without --yes
693    Delete {
694        /// Provider name to delete
695        name: String,
696    },
697}
698
699/// `tags alarms …` — the alarm subfamily (05-06, TAGS-07). History
700/// needs a JOURNAL-PROVISIONED gateway (database connection +
701/// alarm-journal profile + general-alarm-settings — default rigs
702/// refuse exit 6 with the provisioning hint); acknowledge is the
703/// gateway-scope 3-arg form, so `--username` is REQUIRED (no
704/// default-guessing) and ack is deliberately NOT `--yes`-guarded
705/// (acknowledging never un-acknowledges anything — a state-advancing
706/// read-adjacent verb).
707#[derive(Debug, Subcommand)]
708pub enum TagsAlarmsCommand {
709    /// List active alarms — eventId/source/state/priority/name
710    Active {
711        /// Filter by alarm source (e.g. `prov:default`)
712        #[arg(long, value_name = "SOURCE")]
713        source: Option<String>,
714        /// Filter by priority (e.g. `High`)
715        #[arg(long, value_name = "PRIORITY")]
716        priority: Option<String>,
717        /// Filter by state (e.g. `Active, Unacknowledged`)
718        #[arg(long, value_name = "STATE")]
719        state: Option<String>,
720        /// Project holding the deployed routes (default ign-cli)
721        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
722        project: String,
723    },
724    /// Query alarm history — requires a journal-provisioned gateway
725    /// (default rigs refuse with the provisioning hint naming the
726    /// missing chain)
727    History {
728        /// Window start: RFC3339 timestamp or epoch-ms
729        #[arg(long, value_name = "T")]
730        start: String,
731        /// Window end: RFC3339 timestamp or epoch-ms
732        #[arg(long, value_name = "T")]
733        end: String,
734        /// Project holding the deployed routes (default ign-cli)
735        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
736        project: String,
737    },
738    /// Acknowledge alarms (explicit --username: the 3-arg wire form
739    /// needs it). NOT --yes-guarded — acknowledging never
740    /// un-acknowledges anything
741    Ack {
742        /// Alarm event ids to acknowledge (from `alarms active`)
743        #[arg(value_name = "ID", required = true)]
744        ids: Vec<String>,
745        /// Acknowledgement note (default: empty)
746        #[arg(long, value_name = "NOTE")]
747        note: Option<String>,
748        /// The username acknowledging (REQUIRED — the 3-arg wire
749        /// form needs it; the CLI never guesses one)
750        #[arg(long, value_name = "NAME")]
751        username: String,
752        /// Project holding the deployed routes (default ign-cli)
753        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
754        project: String,
755    },
756}
757
758/// `tags history …` — the historian subfamily (05-06, TAGS-08).
759#[derive(Debug, Subcommand)]
760pub enum TagsHistoryCommand {
761    /// Query historical tag values — t_stamp + one column per tag;
762    /// structurally safe anywhere (data requires a provisioned
763    /// historian)
764    Query {
765        /// Tag paths to query, e.g. `[default]T1` (one or more)
766        #[arg(value_name = "PATH", required = true)]
767        paths: Vec<String>,
768        /// Window start: RFC3339 timestamp or epoch-ms
769        #[arg(long, value_name = "T")]
770        start: String,
771        /// Window end: RFC3339 timestamp or epoch-ms
772        #[arg(long, value_name = "T")]
773        end: String,
774        /// Maximum rows returned
775        #[arg(long, value_name = "N")]
776        return_size: Option<i64>,
777        /// Aggregation mode (e.g. `LastValue`, `Average`, `MinMax`);
778        /// default is the route's LastValue
779        #[arg(long, value_name = "MODE")]
780        aggregation: Option<String>,
781        /// Project holding the deployed routes (default ign-cli)
782        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
783        project: String,
784    },
785}
786
787/// Rig args (04-01, RIG-01): `--rig` rides the TOP level (the
788/// SessionsArgs `--type` precedent) so every rig verb shares it. The
789/// command enum carries ONLY the wired verbs — later plans extend it
790/// one variant at a time (the established extend-per-plan chore; no
791/// `unimplemented!()` stubs, every commit compiles clippy-clean).
792#[derive(Debug, clap::Args)]
793pub struct RigArgs {
794    /// Rig to operate on (default: IGNITION_RIG, then [rig].default,
795    /// then the cwd/convention scan)
796    #[arg(long, value_name = "NAME")]
797    pub rig: Option<String>,
798
799    #[command(subcommand)]
800    pub command: RigCommand,
801}
802
803#[derive(Debug, Subcommand)]
804pub enum RigCommand {
805    /// Bring the rig up (compose up -d --wait) and wait for the
806    /// gateway: RUNNING, or uncommissioned-as-data (exit 0 + wizard
807    /// hint in warnings)
808    Up {
809        /// Wait budget in seconds — BOTH compose's --wait-timeout and
810        /// the commissioned probe deadline (default 300)
811        #[arg(long, default_value_t = 300, value_name = "SECS")]
812        timeout: u64,
813    },
814    /// Stop the rig (compose down --remove-orphans; volumes KEPT —
815    /// `reset` owns the teardown half)
816    Down,
817    /// Tear the rig down AND remove its volumes (down -v
818    /// --remove-orphans), then bring it back up fresh — destructive,
819    /// refused without --yes; no stale project/trial state survives
820    Reset {
821        /// Wait budget in seconds — BOTH compose's --wait-timeout and
822        /// the commissioned probe deadline (default 300)
823        #[arg(long, default_value_t = 300, value_name = "SECS")]
824        timeout: u64,
825    },
826    /// Structured status: services, ports, volumes (allowlist JSON;
827    /// a down rig is exit-0 data)
828    Status,
829    /// Stream the rig's container logs (compose logs passthrough —
830    /// raw lines, no envelope in any mode; the third streaming
831    /// exception, README-documented)
832    Logs {
833        /// Lines to show from the end of each service's logs
834        #[arg(long, default_value_t = 200, value_name = "N")]
835        tail: u32,
836        /// Follow: stream new lines as they occur (Ctrl-C stops —
837        /// default process kill, no envelope)
838        #[arg(short = 'f', long)]
839        follow: bool,
840        /// One service's logs only (see `ign rig status` for names)
841        service: Option<String>,
842    },
843    /// Trial-license state: status is credential-free truth; reset
844    /// (guarded) restarts an EXPIRED trial via the mechanism ladder
845    /// (token-auth POST, else native gateway login)
846    Trial(TrialArgs),
847    /// Snapshot the rig's gateway: native gwbk (roaming backup,
848    /// streamed) + per-project exports + manifest.json, composed in a
849    /// timestamped directory — repeatable state
850    Snapshot {
851        /// Output directory (default:
852        /// ./ign-rig-snapshots/<rig>-<yyyyMMdd-HHmmss>/)
853        #[arg(short = 'o', long, value_name = "DIR")]
854        output: Option<PathBuf>,
855    },
856    /// Restore a gwbk onto the rig's gateway — destructive, refused
857    /// without --yes; synchronous restore + restart, then a witnessed
858    /// RUNNING wait
859    Restore {
860        /// The gwbk file to restore (from `ign rig snapshot`)
861        #[arg(long, value_name = "PATH")]
862        file: PathBuf,
863        /// Post-restore RUNNING wait budget in seconds (floored at
864        /// 300 — the gateway restarts after a restore)
865        #[arg(long, default_value_t = 300, value_name = "SECS")]
866        timeout: u64,
867    },
868}
869
870/// Trial subcommands (04-03, RIG-02/03). `status` reads the
871/// unauthenticated trial endpoint + banners cross-check. `reset` is
872/// the family's destructive verb (`--yes`-guarded, exit 2 without —
873/// the reset precedent); the password NEVER rides a flag (env/secret
874/// only — redaction discipline).
875#[derive(Debug, clap::Args)]
876pub struct TrialArgs {
877    #[command(subcommand)]
878    pub command: TrialCommand,
879}
880
881#[derive(Debug, Clone, Subcommand)]
882pub enum TrialCommand {
883    /// Show the trial state: licenseMode, trialState, seconds left,
884    /// expired — plus the banners cross-check. No credential needed
885    /// (the endpoints answer unauthenticated — fresh-rig friendly)
886    Status,
887    /// Reset an EXPIRED trial to a fresh window — destructive,
888    /// refused without --yes. Mechanism ladder: API-token POST
889    /// (IGNITION_TOKEN) → native gateway login (--user /
890    /// IGNITION_USER + IGNITION_PASSWORD). Non-expired trials refuse
891    /// (trial_not_expired)
892    Reset {
893        /// Gateway admin username for the login rung (password comes
894        /// from IGNITION_PASSWORD — never a flag)
895        #[arg(long, value_name = "NAME")]
896        user: Option<String>,
897    },
898}
899
900/// Backup subcommands (07-02, BKUP-01) — the standalone surface of
901/// the Phase 4 gwbk wire. `restore` is the family's destructive verb
902/// (the 8th `--yes`-guarded set member: it REPLACES this gateway's
903/// state); `download` is a streamed read, unguarded.
904#[derive(Debug, clap::Args)]
905pub struct BackupArgs {
906    #[command(subcommand)]
907    pub command: BackupCommand,
908}
909
910#[derive(Debug, Subcommand)]
911pub enum BackupCommand {
912    /// Download a gwbk backup (streamed to disk)
913    Download {
914        /// Output file (default: the gateway's Content-Disposition
915        /// name, else <profile>-backup.gwbk)
916        #[arg(short, long, value_name = "FILE")]
917        output: Option<PathBuf>,
918        /// Backup type: roaming = portable across gateways (default);
919        /// all includes gateway-specific state
920        #[arg(long, value_enum, default_value_t = CliBackupType::Roaming)]
921        r#type: CliBackupType,
922    },
923    /// Restore a gwbk onto THIS gateway — destructive, refused
924    /// without --yes; the gateway restarts and blocks for minutes
925    /// after the restore
926    Restore {
927        /// The gwbk file to restore (from `ign backup download` or
928        /// `ign rig snapshot`)
929        file: PathBuf,
930    },
931}
932
933/// CLI value-enum mirror of the core `BackupType` (ignition-core
934/// stays clap-free; `From` converts at the dispatch seam).
935#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
936pub enum CliBackupType {
937    /// Portable backup (cross-gateway)
938    Roaming,
939    /// Includes gateway-specific state
940    All,
941}
942
943impl From<CliBackupType> for ignition_core::client::backup::BackupType {
944    fn from(value: CliBackupType) -> Self {
945        match value {
946            CliBackupType::Roaming => Self::Roaming,
947            CliBackupType::All => Self::All,
948        }
949    }
950}
951
952/// EAM subcommands (07-02, BKUP-02) — the read-heavy surface with
953/// guarded writes. Every runtime verb honestly reports the
954/// controller-mode state gate (`eam_not_controller` on a stock
955/// gateway — the README documents the manual flip).
956#[derive(Debug, clap::Args)]
957pub struct EamArgs {
958    #[command(subcommand)]
959    pub command: EamCommand,
960}
961
962#[derive(Debug, Subcommand)]
963pub enum EamCommand {
964    /// EAM task run history (the gateway's own newest-first order)
965    History {
966        /// Max entries (default 200 — ALWAYS sent explicitly; the
967        /// server default is unlimited)
968        #[arg(long, value_name = "N")]
969        limit: Option<u32>,
970        /// Substring search over task names
971        #[arg(long, value_name = "TEXT")]
972        search: Option<String>,
973    },
974    /// Task definitions: bare `ign eam tasks` lists; with a name
975    /// shows one definition + its scheduled state
976    Tasks {
977        /// One task definition's detail (omit to list all)
978        name: Option<String>,
979    },
980    /// Task definition writes (create; force-dispatch) — `eam task`
981    /// requires a subcommand (the `rig trial` shape)
982    #[command(subcommand)]
983    Task(EamTaskCommand),
984}
985
986/// `eam task …` — the guarded writes (07-02 Task 3). `new` carries
987/// the typed guard ladder (backup+OnDemand unguarded; mutating
988/// types and any non-OnDemand schedule need --yes;
989/// restore/install/upgrade types refuse outright — see README);
990/// `force` dispatches NOW, always guarded.
991#[derive(Debug, Subcommand)]
992pub enum EamTaskCommand {
993    /// Create a task definition (scheduleMode defaults to OnDemand —
994    /// never auto-fires)
995    New {
996        /// Task definition name (any resource name — e.g. nightly-backup)
997        name: String,
998        /// Task type — the openapi taxonomy, three classes with different guard consequences:
999        ///
1000        /// - benign (no --yes with the default OnDemand schedule): eam_backup
1001        ///
1002        /// - mutating (need --yes — they act on their agent targets when dispatched):
1003        ///   eam_restart, eam_sendProject, eam_sendResource, eam_sendTags,
1004        ///   eam_activateLicense, eam_updateLicense, eam_unactivateLicense
1005        ///
1006        /// - refused (exit 6 eam_task_type_refused — fleet-destructive; run from the EAM console):
1007        ///   eam_restoreBackup, eam_installModules, eam_remoteUpgrade
1008        ///
1009        /// Any OTHER type fails safe to the --yes rung (unknown future
1010        /// types stay accepted, never silently unguarded).
1011        ///
1012        /// Example: ign eam task new nightly-backup eam_backup --target gw-a
1013        r#type: String,
1014        /// Target gateway name (repeatable; the GNET agent id)
1015        #[arg(long, value_name = "NAME")]
1016        target: Vec<String>,
1017        /// Setting as K=V with scalar auto-typing — bool/int ride
1018        /// typed, anything else stays a string (repeatable;
1019        /// arrays/objects need --definition)
1020        #[arg(long, value_name = "K=V")]
1021        setting: Vec<String>,
1022        /// Full-JSON settings file deep-merged over the composed
1023        /// `config.settings` (the typed/array settings path)
1024        #[arg(long, value_name = "PATH", conflicts_with = "setting")]
1025        definition: Option<PathBuf>,
1026        /// Schedule mode (default OnDemand — never auto-fires;
1027        /// Immediate/Scheduled/AtTime/AtDelay require --yes)
1028        #[arg(long, value_enum, default_value_t = ScheduleMode::OnDemand)]
1029        schedule_mode: ScheduleMode,
1030    },
1031    /// Force-dispatch a task NOW — destructive, refused without
1032    /// --yes (it dispatches to the agent targets immediately)
1033    Force {
1034        /// Task definition name to dispatch
1035        name: String,
1036    },
1037}
1038
1039/// Schedule modes (the openapi taxonomy's user-facing subset —
1040/// SuspendedByFailover is system-owned, not a CLI value).
1041#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
1042pub enum ScheduleMode {
1043    /// Never fires on its own (force dispatches it)
1044    OnDemand,
1045    /// Fires immediately on create (requires --yes)
1046    Immediate,
1047    /// Fires on a schedule (requires --yes)
1048    Scheduled,
1049    /// Fires at an absolute time (requires --yes)
1050    AtTime,
1051    /// Fires after a delay (requires --yes)
1052    AtDelay,
1053}
1054
1055impl ScheduleMode {
1056    /// The wire token.
1057    pub fn wire(self) -> &'static str {
1058        match self {
1059            Self::OnDemand => "OnDemand",
1060            Self::Immediate => "Immediate",
1061            Self::Scheduled => "Scheduled",
1062            Self::AtTime => "AtTime",
1063            Self::AtDelay => "AtDelay",
1064        }
1065    }
1066}
1067
1068/// Script subcommands (07-03, SCRPT-01) — the smallest family: ONE
1069/// verb over the already-shipped, already-secured scriptExec route.
1070/// `script` requires a subcommand (the `rig trial` shape — no bare
1071/// row); the opt-in is STRUCTURAL (the route deploys only via
1072/// `ign webdev deploy --with-script-exec`, which persists the
1073/// secret at 0600), so `run` carries NO `--yes` guard by design —
1074/// the deploy flag IS the opt-in and agents need the verb
1075/// non-interactive (the research-adopted decision).
1076#[derive(Debug, clap::Args)]
1077pub struct ScriptArgs {
1078    #[command(subcommand)]
1079    pub command: ScriptCommand,
1080}
1081
1082/// `ign lint` args (07-04, INTR-02) — the ignition-lint delegation:
1083/// PATHS map to `--target <path>` pairs on the child's arg vector;
1084/// `--` passthrough args ride verbatim after them (power users:
1085/// `--profile`, `--checks`, `--fail-on`, …).
1086#[derive(Debug, clap::Args)]
1087pub struct LintArgs {
1088    /// Paths to lint (one --target pair each)
1089    #[arg(value_name = "PATH", required = true)]
1090    pub paths: Vec<String>,
1091    /// Exit with the linter's own exit code (CI mode). Default is
1092    /// the doctor posture: findings + child_exit_code + the parsed
1093    /// report ride as data and the command exits 0 whenever the
1094    /// tool RAN
1095    #[arg(long)]
1096    pub strict: bool,
1097    /// Extra args passed to ignition-lint verbatim (after --)
1098    #[arg(last = true, value_name = "ARGS")]
1099    pub passthrough: Vec<String>,
1100}
1101
1102#[derive(Debug, Subcommand)]
1103pub enum ScriptCommand {
1104    /// Execute gateway-side Python (Jython) — non-interactive, the
1105    /// route's entire purpose
1106    Run {
1107        /// Inline Python source (a one-liner's best form)
1108        #[arg(long, value_name = "PY")]
1109        code: Option<String>,
1110        /// Python source file (`-` reads stdin — the agent pipe
1111        /// path); giving both --code and --file refuses
1112        /// `invalid_input` (exit 2) pre-resolution
1113        #[arg(long, value_name = "PATH")]
1114        file: Option<String>,
1115        /// The deployed routes' project (default ign-cli — where
1116        /// `ign webdev deploy` put scriptExec)
1117        #[arg(long, default_value = "ign-cli", value_name = "NAME")]
1118        project: String,
1119    },
1120}
1121
1122/// Profile subcommands (nested: a struct wrapper carrying the subcommand
1123/// enum, so `Commands::Profile` gets an `Args` payload).
1124#[derive(Debug, clap::Args)]
1125pub struct ProfileArgs {
1126    #[command(subcommand)]
1127    pub command: ProfileCmd,
1128}
1129
1130/// Sessions args: the family filter rides the TOP level so bare
1131/// `ign sessions [--type X]` lists (must-have truth #1) while
1132/// `ign sessions terminate …` carries the destructive half.
1133#[derive(Debug, clap::Args)]
1134pub struct SessionsArgs {
1135    /// Filter to one session family (default: all three merged)
1136    #[arg(long, value_enum)]
1137    pub r#type: Option<SessionType>,
1138
1139    #[command(subcommand)]
1140    pub command: Option<SessionsCmd>,
1141}
1142
1143#[derive(Debug, Subcommand)]
1144pub enum SessionsCmd {
1145    /// Terminate (designer: prune / vision: close) a session —
1146    /// destructive, refused without --yes
1147    Terminate {
1148        /// Session family holding the id
1149        #[arg(long, value_enum)]
1150        r#type: SessionType,
1151        /// Session/client id to terminate (see `ign sessions`)
1152        #[arg(long)]
1153        id: String,
1154        /// Message shown to the session's user (Perspective only)
1155        #[arg(long)]
1156        message: Option<String>,
1157    },
1158}
1159
1160/// Logs args: the query filters ride the TOP level so bare
1161/// `ign logs [-f]` lists/tails (must-have truth #1) while `download`
1162/// and the `loggers` subtree hang off the optional subcommand — the
1163/// SessionsArgs precedent.
1164#[derive(Debug, clap::Args)]
1165pub struct LogsArgs {
1166    /// Only entries from this logger (name prefix)
1167    #[arg(long, value_name = "NAME")]
1168    pub logger: Option<String>,
1169    /// Minimum level to include (server-side filter)
1170    #[arg(long, value_enum, value_name = "LEVEL")]
1171    pub min_level: Option<LogLevel>,
1172    /// Start from an absolute EPOCH-MS or a relative span (500ms, 30s,
1173    /// 5min, 2h) — parsed to epoch-ms at arg-parse time
1174    #[arg(
1175        long,
1176        value_name = "EPOCH_MS|Nms|Ns|Nmin|Nh",
1177        value_parser = parse_since_arg
1178    )]
1179    pub since: Option<i64>,
1180    /// Max entries — the server default is UNLIMITED, never used here
1181    #[arg(long, default_value_t = 200)]
1182    pub limit: i64,
1183    /// Follow: stream new entries as they occur (poll-based)
1184    #[arg(short = 'f', long)]
1185    pub follow: bool,
1186    /// Poll interval in seconds (follow mode)
1187    #[arg(long, default_value_t = 2, value_name = "SECS")]
1188    pub interval: u64,
1189    /// Stop after this many seconds (follow mode; default: until Ctrl-C)
1190    #[arg(long, value_name = "SECS")]
1191    pub timeout: Option<u64>,
1192    #[command(subcommand)]
1193    pub command: Option<LogsCmd>,
1194}
1195
1196#[derive(Debug, Subcommand)]
1197pub enum LogsCmd {
1198    /// Download the log archive — a SQLite .idb, never a zip
1199    Download {
1200        /// Output file (default: the gateway's Content-Disposition
1201        /// name, else <profile>-logs-<ts>.idb)
1202        #[arg(short, long, value_name = "FILE")]
1203        output: Option<PathBuf>,
1204    },
1205    /// List loggers / manage logger levels
1206    Loggers(LoggersArgs),
1207}
1208
1209#[derive(Debug, clap::Args)]
1210pub struct LoggersArgs {
1211    /// Substring search over logger names
1212    #[arg(long, value_name = "TEXT")]
1213    pub search: Option<String>,
1214    #[command(subcommand)]
1215    pub command: Option<LoggersCmd>,
1216}
1217
1218#[derive(Debug, Subcommand)]
1219pub enum LoggersCmd {
1220    /// Set one logger's level — a mutation, refused without --yes
1221    Set {
1222        /// Logger name (see `ign logs loggers`)
1223        name: String,
1224        /// Level to set
1225        #[arg(value_enum)]
1226        level: LogLevel,
1227    },
1228    /// Reset ALL logger levels to defaults — refused without --yes
1229    Reset,
1230}
1231
1232/// The seven spec-documented log levels (TRACE..OFF), value-enum form
1233/// for clap; [`LogLevel::wire`] yields the uppercase wire token.
1234#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
1235pub enum LogLevel {
1236    Trace,
1237    Debug,
1238    Info,
1239    Warn,
1240    Error,
1241    Fatal,
1242    Off,
1243}
1244
1245impl LogLevel {
1246    /// The uppercase wire token the gateway expects.
1247    pub fn wire(self) -> &'static str {
1248        match self {
1249            Self::Trace => "TRACE",
1250            Self::Debug => "DEBUG",
1251            Self::Info => "INFO",
1252            Self::Warn => "WARN",
1253            Self::Error => "ERROR",
1254            Self::Fatal => "FATAL",
1255            Self::Off => "OFF",
1256        }
1257    }
1258}
1259
1260/// clap value parser delegating to the core `--since` grammar against
1261/// the current time (a relative span resolves at parse time) — invalid
1262/// specs are clap usage errors (exit 2) like any bad flag value.
1263fn parse_since_arg(spec: &str) -> Result<i64, String> {
1264    let now_ms = std::time::SystemTime::now()
1265        .duration_since(std::time::UNIX_EPOCH)
1266        .expect("system clock is after the unix epoch")
1267        .as_millis() as i64;
1268    ignition_core::actions::logs::parse_since(spec, now_ms)
1269}
1270
1271/// CLI value-enum mirrors of the core action enums (ignition-core stays
1272/// clap-free; `From` converts at the dispatch seam).
1273#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
1274pub enum SessionType {
1275    /// Designer sessions
1276    Designer,
1277    /// Perspective browser sessions
1278    Perspective,
1279    /// Vision clients
1280    Vision,
1281}
1282
1283impl From<SessionType> for ignition_core::actions::sessions::SessionType {
1284    fn from(value: SessionType) -> Self {
1285        match value {
1286            SessionType::Designer => Self::Designer,
1287            SessionType::Perspective => Self::Perspective,
1288            SessionType::Vision => Self::Vision,
1289        }
1290    }
1291}
1292
1293#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
1294pub enum ConnectionType {
1295    /// Database connections
1296    Database,
1297    /// OPC connections
1298    Opc,
1299}
1300
1301impl From<ConnectionType> for ignition_core::actions::connections::ConnectionType {
1302    fn from(value: ConnectionType) -> Self {
1303        match value {
1304            ConnectionType::Database => Self::Database,
1305            ConnectionType::Opc => Self::Opc,
1306        }
1307    }
1308}
1309
1310/// Import collision policy (03-02). Exactly the two values REST
1311/// exposes — `merge` is the Designer import popup's vocabulary and is
1312/// NOT a value: clap's invalid-value error lists the two real
1313/// choices, and the README documents merge as Designer-only (the
1314/// sanctioned rejection-with-hint shape).
1315#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
1316pub enum CollisionPolicy {
1317    /// Refuse when the project already exists (default)
1318    Abort,
1319    /// Replace the ENTIRE project (resources absent from the ZIP are
1320    /// deleted) — destructive, requires --yes
1321    Overwrite,
1322}
1323
1324impl From<CollisionPolicy> for ignition_core::actions::projects::CollisionPolicy {
1325    fn from(value: CollisionPolicy) -> Self {
1326        match value {
1327            CollisionPolicy::Abort => Self::Abort,
1328            CollisionPolicy::Overwrite => Self::Overwrite,
1329        }
1330    }
1331}
1332
1333#[derive(Debug, Subcommand)]
1334#[command(arg_required_else_help = true)]
1335pub enum ProfileCmd {
1336    /// Add (or overwrite) a gateway profile
1337    Add {
1338        /// Profile name
1339        name: String,
1340        /// Gateway base URL (e.g. http://localhost:9088)
1341        url: String,
1342        /// Optional display label
1343        #[arg(long, value_name = "TEXT")]
1344        label: Option<String>,
1345        /// Name of the env var holding the auth token
1346        #[arg(long, value_name = "VAR")]
1347        token_env: Option<String>,
1348        /// Keyring user string for the token (service is always ignition-cli)
1349        #[arg(long, value_name = "USER")]
1350        keyring: Option<String>,
1351        /// Name of the env var holding the basic-auth user (with --password-env)
1352        #[arg(long, value_name = "VAR")]
1353        user_env: Option<String>,
1354        /// Name of the env var holding the basic-auth password (with --user-env)
1355        #[arg(long, value_name = "VAR")]
1356        password_env: Option<String>,
1357        /// Make this profile the active one
1358        #[arg(long)]
1359        active: bool,
1360    },
1361    /// List configured profiles
1362    List,
1363    /// Switch the active profile
1364    Use {
1365        /// Profile name to activate
1366        name: String,
1367    },
1368}