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 /// Run as an MCP server (JSON-RPC 2.0 over stdio): a frugal gateway exposing
90 /// list_capabilities, describe_capability, and invoke meta-tools.
91 Mcp,
92}
93
94/// Actions under the `services` subcommand.
95#[derive(Subcommand, Debug)]
96pub enum ServicesAction {
97 /// List units.
98 List {
99 /// Filter by active state (e.g. `active`, `failed`).
100 #[arg(long)]
101 state: Option<String>,
102 },
103 /// Show one unit's status.
104 Status {
105 /// Unit to inspect.
106 unit: String,
107 },
108 /// Read a unit's journal.
109 Logs {
110 /// Unit whose journal to read.
111 unit: String,
112 /// Only entries since this time (journalctl `--since` syntax).
113 #[arg(long)]
114 since: Option<String>,
115 /// Minimum priority to include (journalctl `--priority` syntax).
116 #[arg(long)]
117 priority: Option<String>,
118 /// Limit output to the last N entries.
119 #[arg(long)]
120 lines: Option<u32>,
121 /// Stream new entries as they arrive.
122 #[arg(long)]
123 follow: bool,
124 },
125 /// Start a unit.
126 Start {
127 /// Unit to start.
128 unit: String,
129 },
130 /// Stop a unit.
131 Stop {
132 /// Unit to stop.
133 unit: String,
134 },
135 /// Restart a unit.
136 Restart {
137 /// Unit to restart.
138 unit: String,
139 },
140 /// Reload a unit's configuration.
141 Reload {
142 /// Unit to reload.
143 unit: String,
144 },
145 /// Enable a unit (optionally start it now).
146 Enable {
147 /// Unit to enable.
148 unit: String,
149 /// Also start the unit immediately.
150 #[arg(long)]
151 now: bool,
152 },
153 /// Disable a unit (optionally stop it now).
154 Disable {
155 /// Unit to disable.
156 unit: String,
157 /// Also stop the unit immediately.
158 #[arg(long)]
159 now: bool,
160 },
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 fn cli(args: &[&str]) -> Cli {
168 Cli::try_parse_from(args).expect("args parse")
169 }
170
171 #[test]
172 fn resolved_host_defaults_to_localhost() {
173 assert_eq!(
174 cli(&["fez", "services", "list"]).resolved_host(),
175 "localhost"
176 );
177 }
178
179 #[test]
180 fn resolved_host_normalizes_local_alias() {
181 // `--host local` must report the same label as the transport uses
182 // (`localhost`), so the envelope/audit host never drifts from the
183 // host the bridge actually runs on.
184 assert_eq!(
185 cli(&["fez", "--host", "local", "services", "list"]).resolved_host(),
186 "localhost"
187 );
188 }
189
190 #[test]
191 fn resolved_host_passes_through_explicit_host() {
192 assert_eq!(
193 cli(&["fez", "--host", "fedora@box.example", "services", "list"]).resolved_host(),
194 "fedora@box.example"
195 );
196 }
197}