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}