Skip to main content

fez/
cli.rs

1use clap::{CommandFactory, FromArgMatches, Parser, Subcommand};
2use clap_complete::Shell;
3
4#[derive(Parser, Debug)]
5#[command(
6    name = "fez",
7    version,
8    about = "Agent-native management CLI for Fedora/RHEL"
9)]
10/// Top-level parsed command line.
11pub struct Cli {
12    /// Target host (localhost when omitted). May be a host, user@host, or ssh_config alias.
13    #[arg(long, global = true)]
14    pub host: Option<String>,
15
16    /// Emit the machine-readable fez/v1 JSON envelope.
17    #[arg(long, global = true)]
18    pub json: bool,
19
20    /// Preview the action without connecting or mutating (no-op for reads).
21    #[arg(long, global = true)]
22    pub dry_run: bool,
23
24    /// Override command-specific safety guardrails. See command help for exact risks.
25    #[arg(long, global = true)]
26    pub force: bool,
27
28    /// The subcommand to run.
29    #[command(subcommand)]
30    pub command: TopCommand,
31}
32
33impl Cli {
34    /// The host label for the response envelope and audit records.
35    ///
36    /// Resolves the global `--host` flag through the same normalization the
37    /// transport applies, so the reported label never drifts from the host the
38    /// bridge actually runs on. In particular `--host local` and an omitted
39    /// `--host` both report `localhost`, matching [`crate::transport::from_host`].
40    #[must_use]
41    pub fn resolved_host(&self) -> String {
42        crate::transport::from_host(self.host.as_deref()).host_label()
43    }
44}
45
46/// The derived clap command tree before registry enrichment.
47pub fn raw_command() -> clap::Command {
48    <Cli as CommandFactory>::command()
49}
50
51/// The fully enriched clap command (registry long-about and examples injected).
52pub fn command() -> clap::Command {
53    crate::capability::help::inject(raw_command())
54}
55
56/// Whether the raw argv requested machine-readable output (`--json`).
57///
58/// Used to decide error rendering before clap has parsed successfully: a parse
59/// error means we have no [`Cli`] to read `json` from, so we scan the raw args.
60/// `--json` is a boolean flag, so a bare token match is sufficient; it never
61/// takes a value that could be `--json`. Scanning stops at the `--`
62/// end-of-options marker, so a `--json` that appears only as a positional after
63/// `--` does not flip a usage error into a JSON envelope.
64fn wants_json<I, S>(args: I) -> bool
65where
66    I: IntoIterator<Item = S>,
67    S: AsRef<str>,
68{
69    for arg in args {
70        let arg = arg.as_ref();
71        if arg == "--" {
72            return false;
73        }
74        if arg == "--json" {
75            return true;
76        }
77    }
78    false
79}
80
81/// Parse argv to a [`Cli`], or render a clap error and return the exit code.
82///
83/// Returns `Ok(cli)` on a successful parse.
84///
85/// # Errors
86///
87/// Returns `Err(exit_code)` when the process should exit immediately, after
88/// this function has already printed whatever the user should see:
89///
90/// - `Err(0)` for `--help`/`--version`: clap renders them to stdout (not
91///   errors), then we exit cleanly.
92/// - `Err(2)` for a clap **usage** error (missing/invalid argument, unknown
93///   flag). This honors `--json`: when requested, it emits a `fez/v1` error
94///   envelope on stdout (code `usage`) instead of clap's stderr text (issue
95///   #52). Without `--json`, clap's human-facing rendering is preserved
96///   unchanged.
97pub fn parse_or_render() -> std::result::Result<Cli, i32> {
98    let argv: Vec<std::ffi::OsString> = std::env::args_os().collect();
99    match command().try_get_matches_from(&argv) {
100        Ok(matches) => Ok(Cli::from_arg_matches(&matches).expect("clap validated args")),
101        Err(err) => {
102            use clap::error::ErrorKind;
103            // Help/version are not failures: let clap print them, exit 0.
104            if matches!(
105                err.kind(),
106                ErrorKind::DisplayHelp
107                    | ErrorKind::DisplayVersion
108                    | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
109            ) {
110                let _ = err.print();
111                return Err(0);
112            }
113            let json = wants_json(argv.iter().map(|s| s.to_string_lossy().into_owned()));
114            if json {
115                // Render a usage envelope on stdout. The host is localhost: a
116                // parse error never reached a transport.
117                let message = clap_error_message(&err);
118                let env = crate::envelope::Envelope::error(
119                    "Error",
120                    "localhost",
121                    crate::envelope::ApiError {
122                        code: "usage".into(),
123                        message,
124                        detail: None,
125                    },
126                );
127                println!("{}", env.to_json_string());
128                Err(2)
129            } else {
130                let _ = err.print();
131                Err(err.exit_code())
132            }
133        }
134    }
135}
136
137/// Reduce a clap error to a single user-actionable line for the envelope.
138///
139/// clap renders the diagnostic, then a blank line, then a `Usage:` block and a
140/// "for more information" footer. The actionable part is everything before that
141/// first blank line; we join it into one line (so "missing arg" plus the listed
142/// arg names stay together) and strip the leading `error: ` prefix.
143fn clap_error_message(err: &clap::Error) -> String {
144    let rendered = err.render().to_string();
145    let mut parts: Vec<String> = Vec::new();
146    for raw in rendered.lines() {
147        let line = raw.trim();
148        if line.is_empty() {
149            // Blank line separates the diagnostic from the usage/footer block.
150            break;
151        }
152        parts.push(line.to_string());
153    }
154    if parts.is_empty() {
155        return "usage error".to_string();
156    }
157    let joined = parts.join(" ");
158    joined
159        .strip_prefix("error: ")
160        .unwrap_or(&joined)
161        .to_string()
162}
163
164/// The top-level subcommands fez accepts.
165#[derive(Subcommand, Debug)]
166pub enum TopCommand {
167    /// List capability ids for on-demand discovery.
168    Capabilities,
169    /// Describe one capability (inputs, output kind, flags, examples).
170    Describe {
171        /// Dotted capability id to describe (e.g. `services.start`).
172        capability: String,
173    },
174    /// Print the agent contract: discovery loop, envelope, exit codes, env vars.
175    Guide,
176    /// Generate a shell completion script on stdout.
177    Completions {
178        /// Shell to generate completions for.
179        #[arg(value_enum)]
180        shell: Shell,
181    },
182    /// Emit the roff man page on stdout (used by packaging).
183    #[command(hide = true)]
184    Man,
185    /// Manage systemd services.
186    Services {
187        /// The `services` action to perform.
188        #[command(subcommand)]
189        action: ServicesAction,
190    },
191    /// Manage RPM packages (via dnf5daemon).
192    Packages {
193        /// The `packages` action to perform.
194        #[command(subcommand)]
195        action: PackagesAction,
196    },
197    /// Inspect NetworkManager devices and connections.
198    Network {
199        /// The `network` action to perform.
200        #[command(subcommand)]
201        action: NetworkAction,
202    },
203    /// Manage the firewall (via firewalld).
204    Firewall {
205        /// The `firewall` action to perform.
206        #[command(subcommand)]
207        action: FirewallAction,
208    },
209    /// Run as an MCP server (JSON-RPC 2.0 over stdio): a frugal gateway exposing
210    /// list_capabilities, describe_capability, and invoke meta-tools.
211    Mcp {
212        /// Also expose one strict JSON-schema tool per fez capability.
213        #[arg(long)]
214        expanded_tools: bool,
215    },
216}
217
218/// Actions under the `services` subcommand.
219#[derive(Subcommand, Debug)]
220pub enum ServicesAction {
221    /// List units.
222    List {
223        /// Filter by active state (e.g. `active`, `failed`).
224        #[arg(long)]
225        state: Option<String>,
226    },
227    /// Show one unit's status.
228    Status {
229        /// Unit to inspect.
230        unit: String,
231    },
232    /// Read a unit's journal.
233    Logs {
234        /// Unit whose journal to read.
235        unit: String,
236        /// Only entries since this time (journalctl `--since` syntax).
237        #[arg(long)]
238        since: Option<String>,
239        /// Minimum priority to include (journalctl `--priority` syntax).
240        #[arg(long)]
241        priority: Option<String>,
242        /// Limit output to the last N entries.
243        #[arg(long)]
244        lines: Option<u32>,
245        /// Stream new entries as they arrive.
246        #[arg(long)]
247        follow: bool,
248    },
249    /// Start a unit.
250    Start {
251        /// Unit to start.
252        unit: String,
253    },
254    /// Stop a unit.
255    Stop {
256        /// Unit to stop.
257        unit: String,
258    },
259    /// Restart a unit.
260    Restart {
261        /// Unit to restart.
262        unit: String,
263    },
264    /// Reload a unit's configuration.
265    Reload {
266        /// Unit to reload.
267        unit: String,
268    },
269    /// Enable a unit (optionally start it now).
270    Enable {
271        /// Unit to enable.
272        unit: String,
273        /// Also start the unit immediately.
274        #[arg(long)]
275        now: bool,
276    },
277    /// Disable a unit (optionally stop it now).
278    Disable {
279        /// Unit to disable.
280        unit: String,
281        /// Also stop the unit immediately.
282        #[arg(long)]
283        now: bool,
284    },
285}
286
287/// Actions under the `packages` subcommand.
288#[derive(Subcommand, Debug)]
289pub enum PackagesAction {
290    /// List packages.
291    List {
292        /// List only installed packages (the default).
293        #[arg(long, conflicts_with = "available")]
294        installed: bool,
295        /// List available packages instead of installed.
296        #[arg(long)]
297        available: bool,
298        /// Restrict to packages whose repo id exactly matches. Repeatable; a
299        /// package is kept if its repo id equals any given value (OR).
300        #[arg(long = "repo")]
301        repo: Vec<String>,
302        /// Restrict to packages whose name contains this substring.
303        #[arg(long)]
304        name: Option<String>,
305        /// Maximum number of rows to return.
306        #[arg(long)]
307        limit: Option<usize>,
308        /// Number of matching rows to skip before returning results.
309        #[arg(long, default_value_t = 0)]
310        offset: usize,
311    },
312    /// Show one package's full attributes.
313    Info {
314        /// Package spec to describe.
315        spec: String,
316    },
317    /// Search packages by name, summary, or provides.
318    Search {
319        /// Pattern to match.
320        pattern: String,
321    },
322    /// List available upgrades.
323    CheckUpdate,
324    /// List repositories and their enabled state.
325    Repolist {
326        /// Show only enabled repositories (the default).
327        #[arg(long, conflicts_with_all = ["disabled", "all"])]
328        enabled: bool,
329        /// Show only disabled repositories.
330        #[arg(long, conflicts_with = "all")]
331        disabled: bool,
332        /// Show all repositories.
333        #[arg(long)]
334        all: bool,
335    },
336    /// Install packages.
337    Install {
338        /// Package specs to install.
339        #[arg(required = true)]
340        specs: Vec<String>,
341    },
342    /// Remove packages.
343    Remove {
344        /// Package specs to remove.
345        #[arg(required = true)]
346        specs: Vec<String>,
347    },
348    /// Upgrade packages (all if none given).
349    Upgrade {
350        /// Package specs to upgrade; empty means upgrade everything.
351        specs: Vec<String>,
352    },
353}
354
355/// Actions under the `network` subcommand.
356#[derive(Subcommand, Debug)]
357pub enum NetworkAction {
358    /// List network devices.
359    List {
360        /// Include every device, including unmanaged virtual interfaces.
361        #[arg(long)]
362        all: bool,
363    },
364    /// Show one device's full network detail.
365    Show {
366        /// Device interface name to inspect (e.g. `enp1s0`).
367        device: String,
368    },
369}
370
371/// Actions under the `firewall` subcommand.
372#[derive(Subcommand, Debug)]
373pub enum FirewallAction {
374    /// Show firewall state, default zone, panic mode, and pending changes.
375    Status,
376    /// List zones with a per-zone summary.
377    List,
378    /// Show one zone's full detail.
379    Show {
380        /// Zone to inspect (e.g. `public`).
381        zone: String,
382    },
383    /// List the service catalog firewalld knows about.
384    Services,
385    /// Add a service to a zone (runtime only; confirm to persist).
386    AddService {
387        /// Service name to add (e.g. `http`).
388        service: String,
389        /// Zone to add to (defaults to the default zone).
390        #[arg(long)]
391        zone: Option<String>,
392        /// Auto-revert the runtime rule after this many seconds.
393        #[arg(long)]
394        timeout: Option<u32>,
395    },
396    /// Remove a service from a zone (runtime only; confirm to persist).
397    RemoveService {
398        /// Service name to remove.
399        service: String,
400        /// Zone to remove from (defaults to the default zone).
401        #[arg(long)]
402        zone: Option<String>,
403    },
404    /// Add a port to a zone (runtime only; confirm to persist).
405    AddPort {
406        /// Port spec as `port/proto` (e.g. `8080/tcp`).
407        port: String,
408        /// Zone to add to (defaults to the default zone).
409        #[arg(long)]
410        zone: Option<String>,
411        /// Auto-revert the runtime rule after this many seconds.
412        #[arg(long)]
413        timeout: Option<u32>,
414    },
415    /// Remove a port from a zone (runtime only; confirm to persist).
416    RemovePort {
417        /// Port spec as `port/proto` (e.g. `8080/tcp`).
418        port: String,
419        /// Zone to remove from (defaults to the default zone).
420        #[arg(long)]
421        zone: Option<String>,
422    },
423    /// Set the default zone (gated: requires --force).
424    SetDefaultZone {
425        /// Zone to make default.
426        zone: String,
427    },
428    /// Reload permanent config into runtime (discards uncommitted runtime changes).
429    Reload,
430    /// Persist the current runtime config to permanent (runtimeToPermanent).
431    Confirm,
432    /// Toggle panic mode (drops all traffic when on).
433    Panic {
434        /// Panic state to set.
435        #[arg(value_parser = ["on", "off"])]
436        state: String,
437    },
438    /// Enable or disable masquerade (SNAT) for a zone (runtime only; confirm to persist).
439    Masquerade {
440        /// Masquerade state to set.
441        #[arg(value_parser = ["on", "off"])]
442        state: String,
443        /// Zone to change (defaults to the default zone).
444        #[arg(long)]
445        zone: Option<String>,
446        /// Auto-revert the runtime rule after this many seconds (ignored for `off`).
447        #[arg(long)]
448        timeout: Option<u32>,
449    },
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    fn cli(args: &[&str]) -> Cli {
457        Cli::try_parse_from(args).expect("args parse")
458    }
459
460    #[test]
461    fn wants_json_detects_flag_anywhere() {
462        assert!(wants_json(["fez", "--json", "services", "status"]));
463        assert!(wants_json(["fez", "services", "status", "--json"]));
464        assert!(!wants_json(["fez", "services", "status"]));
465    }
466
467    #[test]
468    fn wants_json_respects_double_dash() {
469        // `--json` after the end-of-options marker is a positional, not the flag.
470        assert!(!wants_json(["fez", "--", "--json"]));
471        // `--json` before `--` still enables JSON mode.
472        assert!(wants_json(["fez", "--json", "--", "x"]));
473    }
474
475    #[test]
476    fn clap_error_message_joins_missing_args_and_strips_prefix() {
477        // A missing required positional renders "error: ...not provided:" then
478        // the arg names on the next line; the message must keep them together
479        // and drop the `error: ` prefix.
480        let err = Cli::try_parse_from(["fez", "services", "status"]).unwrap_err();
481        let msg = clap_error_message(&err);
482        assert!(!msg.starts_with("error:"), "prefix not stripped: {msg}");
483        assert!(msg.contains("UNIT"), "arg name missing: {msg}");
484        assert!(!msg.contains('\n'), "message should be one line: {msg}");
485    }
486
487    #[test]
488    fn clap_error_message_renders_unknown_flag() {
489        let err = Cli::try_parse_from(["fez", "services", "list", "--bogus"]).unwrap_err();
490        let msg = clap_error_message(&err);
491        assert!(msg.contains("--bogus"), "{msg}");
492    }
493
494    #[test]
495    fn resolved_host_defaults_to_localhost() {
496        assert_eq!(
497            cli(&["fez", "services", "list"]).resolved_host(),
498            "localhost"
499        );
500    }
501
502    #[test]
503    fn resolved_host_normalizes_local_alias() {
504        // `--host local` must report the same label as the transport uses
505        // (`localhost`), so the envelope/audit host never drifts from the
506        // host the bridge actually runs on.
507        assert_eq!(
508            cli(&["fez", "--host", "local", "services", "list"]).resolved_host(),
509            "localhost"
510        );
511    }
512
513    #[test]
514    fn resolved_host_passes_through_explicit_host() {
515        assert_eq!(
516            cli(&["fez", "--host", "fedora@box.example", "services", "list"]).resolved_host(),
517            "fedora@box.example"
518        );
519    }
520
521    #[test]
522    fn firewall_masquerade_parses_state_zone_timeout() {
523        let c = cli(&[
524            "fez",
525            "firewall",
526            "masquerade",
527            "on",
528            "--zone",
529            "public",
530            "--timeout",
531            "60",
532        ]);
533        match c.command {
534            TopCommand::Firewall {
535                action:
536                    FirewallAction::Masquerade {
537                        state,
538                        zone,
539                        timeout,
540                    },
541            } => {
542                assert_eq!(state, "on");
543                assert_eq!(zone.as_deref(), Some("public"));
544                assert_eq!(timeout, Some(60));
545            }
546            other => panic!("unexpected parse: {other:?}"),
547        }
548    }
549
550    #[test]
551    fn firewall_masquerade_rejects_bad_state() {
552        assert!(Cli::try_parse_from(["fez", "firewall", "masquerade", "maybe"]).is_err());
553    }
554}