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    /// 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}