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}