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 the protected-unit policy and skip interactive confirmation.
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/// Parse argv through the enriched command. Exits via clap on `--help`/errors.
57pub fn parse() -> Cli {
58    let matches = command().get_matches();
59    Cli::from_arg_matches(&matches).expect("clap validated args")
60}
61
62/// The top-level subcommands fez accepts.
63#[derive(Subcommand, Debug)]
64pub enum TopCommand {
65    /// List capability ids for on-demand discovery.
66    Capabilities,
67    /// Describe one capability (inputs, output kind, flags, examples).
68    Describe {
69        /// Dotted capability id to describe (e.g. `services.start`).
70        capability: String,
71    },
72    /// Print the agent contract: discovery loop, envelope, exit codes, env vars.
73    Guide,
74    /// Generate a shell completion script on stdout.
75    Completions {
76        /// Shell to generate completions for.
77        #[arg(value_enum)]
78        shell: Shell,
79    },
80    /// Emit the roff man page on stdout (used by packaging).
81    #[command(hide = true)]
82    Man,
83    /// Manage systemd services.
84    Services {
85        /// The `services` action to perform.
86        #[command(subcommand)]
87        action: ServicesAction,
88    },
89    /// Manage RPM packages (via dnf5daemon).
90    Packages {
91        /// The `packages` action to perform.
92        #[command(subcommand)]
93        action: PackagesAction,
94    },
95    /// Inspect NetworkManager devices and connections.
96    Network {
97        /// The `network` action to perform.
98        #[command(subcommand)]
99        action: NetworkAction,
100    },
101    /// Manage the firewall (via firewalld).
102    Firewall {
103        /// The `firewall` action to perform.
104        #[command(subcommand)]
105        action: FirewallAction,
106    },
107    /// Run as an MCP server (JSON-RPC 2.0 over stdio): a frugal gateway exposing
108    /// list_capabilities, describe_capability, and invoke meta-tools.
109    Mcp,
110}
111
112/// Actions under the `services` subcommand.
113#[derive(Subcommand, Debug)]
114pub enum ServicesAction {
115    /// List units.
116    List {
117        /// Filter by active state (e.g. `active`, `failed`).
118        #[arg(long)]
119        state: Option<String>,
120    },
121    /// Show one unit's status.
122    Status {
123        /// Unit to inspect.
124        unit: String,
125    },
126    /// Read a unit's journal.
127    Logs {
128        /// Unit whose journal to read.
129        unit: String,
130        /// Only entries since this time (journalctl `--since` syntax).
131        #[arg(long)]
132        since: Option<String>,
133        /// Minimum priority to include (journalctl `--priority` syntax).
134        #[arg(long)]
135        priority: Option<String>,
136        /// Limit output to the last N entries.
137        #[arg(long)]
138        lines: Option<u32>,
139        /// Stream new entries as they arrive.
140        #[arg(long)]
141        follow: bool,
142    },
143    /// Start a unit.
144    Start {
145        /// Unit to start.
146        unit: String,
147    },
148    /// Stop a unit.
149    Stop {
150        /// Unit to stop.
151        unit: String,
152    },
153    /// Restart a unit.
154    Restart {
155        /// Unit to restart.
156        unit: String,
157    },
158    /// Reload a unit's configuration.
159    Reload {
160        /// Unit to reload.
161        unit: String,
162    },
163    /// Enable a unit (optionally start it now).
164    Enable {
165        /// Unit to enable.
166        unit: String,
167        /// Also start the unit immediately.
168        #[arg(long)]
169        now: bool,
170    },
171    /// Disable a unit (optionally stop it now).
172    Disable {
173        /// Unit to disable.
174        unit: String,
175        /// Also stop the unit immediately.
176        #[arg(long)]
177        now: bool,
178    },
179}
180
181/// Actions under the `packages` subcommand.
182#[derive(Subcommand, Debug)]
183pub enum PackagesAction {
184    /// List packages.
185    List {
186        /// List only installed packages (the default).
187        #[arg(long, conflicts_with = "available")]
188        installed: bool,
189        /// List available packages instead of installed.
190        #[arg(long)]
191        available: bool,
192        /// Restrict to packages from these repositories.
193        #[arg(long = "repo")]
194        repo: Vec<String>,
195    },
196    /// Show one package's full attributes.
197    Info {
198        /// Package spec to describe.
199        spec: String,
200    },
201    /// Search packages by name, summary, or provides.
202    Search {
203        /// Pattern to match.
204        pattern: String,
205    },
206    /// List available upgrades.
207    CheckUpdate,
208    /// List repositories and their enabled state.
209    Repolist {
210        /// Show only enabled repositories (the default).
211        #[arg(long, conflicts_with_all = ["disabled", "all"])]
212        enabled: bool,
213        /// Show only disabled repositories.
214        #[arg(long, conflicts_with = "all")]
215        disabled: bool,
216        /// Show all repositories.
217        #[arg(long)]
218        all: bool,
219    },
220    /// Install packages.
221    Install {
222        /// Package specs to install.
223        #[arg(required = true)]
224        specs: Vec<String>,
225    },
226    /// Remove packages.
227    Remove {
228        /// Package specs to remove.
229        #[arg(required = true)]
230        specs: Vec<String>,
231    },
232    /// Upgrade packages (all if none given).
233    Upgrade {
234        /// Package specs to upgrade; empty means upgrade everything.
235        specs: Vec<String>,
236    },
237}
238
239/// Actions under the `network` subcommand.
240#[derive(Subcommand, Debug)]
241pub enum NetworkAction {
242    /// List network devices.
243    List {
244        /// Include every device, including unmanaged virtual interfaces.
245        #[arg(long)]
246        all: bool,
247    },
248    /// Show one device's full network detail.
249    Show {
250        /// Device interface name to inspect (e.g. `enp1s0`).
251        device: String,
252    },
253}
254
255/// Actions under the `firewall` subcommand.
256#[derive(Subcommand, Debug)]
257pub enum FirewallAction {
258    /// Show firewall state, default zone, panic mode, and pending changes.
259    Status,
260    /// List zones with a per-zone summary.
261    List,
262    /// Show one zone's full detail.
263    Show {
264        /// Zone to inspect (e.g. `public`).
265        zone: String,
266    },
267    /// List the service catalog firewalld knows about.
268    Services,
269    /// Add a service to a zone (runtime only; confirm to persist).
270    AddService {
271        /// Service name to add (e.g. `http`).
272        service: String,
273        /// Zone to add to (defaults to the default zone).
274        #[arg(long)]
275        zone: Option<String>,
276        /// Auto-revert the runtime rule after this many seconds.
277        #[arg(long)]
278        timeout: Option<u32>,
279    },
280    /// Remove a service from a zone (runtime only; confirm to persist).
281    RemoveService {
282        /// Service name to remove.
283        service: String,
284        /// Zone to remove from (defaults to the default zone).
285        #[arg(long)]
286        zone: Option<String>,
287    },
288    /// Add a port to a zone (runtime only; confirm to persist).
289    AddPort {
290        /// Port spec as `port/proto` (e.g. `8080/tcp`).
291        port: String,
292        /// Zone to add to (defaults to the default zone).
293        #[arg(long)]
294        zone: Option<String>,
295        /// Auto-revert the runtime rule after this many seconds.
296        #[arg(long)]
297        timeout: Option<u32>,
298    },
299    /// Remove a port from a zone (runtime only; confirm to persist).
300    RemovePort {
301        /// Port spec as `port/proto` (e.g. `8080/tcp`).
302        port: String,
303        /// Zone to remove from (defaults to the default zone).
304        #[arg(long)]
305        zone: Option<String>,
306    },
307    /// Set the default zone (gated: requires --force).
308    SetDefaultZone {
309        /// Zone to make default.
310        zone: String,
311    },
312    /// Reload permanent config into runtime (discards uncommitted runtime changes).
313    Reload,
314    /// Persist the current runtime config to permanent (runtimeToPermanent).
315    Confirm,
316    /// Toggle panic mode (drops all traffic when on).
317    Panic {
318        /// Panic state to set.
319        #[arg(value_parser = ["on", "off"])]
320        state: String,
321    },
322    /// Enable or disable masquerade (SNAT) for a zone (runtime only; confirm to persist).
323    Masquerade {
324        /// Masquerade state to set.
325        #[arg(value_parser = ["on", "off"])]
326        state: String,
327        /// Zone to change (defaults to the default zone).
328        #[arg(long)]
329        zone: Option<String>,
330        /// Auto-revert the runtime rule after this many seconds (ignored for `off`).
331        #[arg(long)]
332        timeout: Option<u32>,
333    },
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    fn cli(args: &[&str]) -> Cli {
341        Cli::try_parse_from(args).expect("args parse")
342    }
343
344    #[test]
345    fn resolved_host_defaults_to_localhost() {
346        assert_eq!(
347            cli(&["fez", "services", "list"]).resolved_host(),
348            "localhost"
349        );
350    }
351
352    #[test]
353    fn resolved_host_normalizes_local_alias() {
354        // `--host local` must report the same label as the transport uses
355        // (`localhost`), so the envelope/audit host never drifts from the
356        // host the bridge actually runs on.
357        assert_eq!(
358            cli(&["fez", "--host", "local", "services", "list"]).resolved_host(),
359            "localhost"
360        );
361    }
362
363    #[test]
364    fn resolved_host_passes_through_explicit_host() {
365        assert_eq!(
366            cli(&["fez", "--host", "fedora@box.example", "services", "list"]).resolved_host(),
367            "fedora@box.example"
368        );
369    }
370
371    #[test]
372    fn firewall_masquerade_parses_state_zone_timeout() {
373        let c = cli(&[
374            "fez",
375            "firewall",
376            "masquerade",
377            "on",
378            "--zone",
379            "public",
380            "--timeout",
381            "60",
382        ]);
383        match c.command {
384            TopCommand::Firewall {
385                action:
386                    FirewallAction::Masquerade {
387                        state,
388                        zone,
389                        timeout,
390                    },
391            } => {
392                assert_eq!(state, "on");
393                assert_eq!(zone.as_deref(), Some("public"));
394                assert_eq!(timeout, Some(60));
395            }
396            other => panic!("unexpected parse: {other:?}"),
397        }
398    }
399
400    #[test]
401    fn firewall_masquerade_rejects_bad_state() {
402        assert!(Cli::try_parse_from(["fez", "firewall", "masquerade", "maybe"]).is_err());
403    }
404}