Skip to main content

omni_dev/cli/
config.rs

1//! Configuration-related CLI commands.
2
3use anyhow::Result;
4use clap::{Parser, Subcommand};
5
6use crate::claude::model_config::{get_model_registry, ModelSource, MODELS_YAML};
7
8/// Configuration operations.
9#[derive(Parser)]
10pub struct ConfigCommand {
11    /// Configuration subcommand to execute.
12    #[command(subcommand)]
13    pub command: ConfigSubcommands,
14}
15
16/// Configuration subcommands.
17#[derive(Subcommand)]
18pub enum ConfigSubcommands {
19    /// AI model configuration and information.
20    Models(ModelsCommand),
21}
22
23/// Models operations.
24#[derive(Parser)]
25pub struct ModelsCommand {
26    /// Models subcommand to execute.
27    #[command(subcommand)]
28    pub command: ModelsSubcommands,
29}
30
31/// Models subcommands.
32#[derive(Subcommand)]
33pub enum ModelsSubcommands {
34    /// Shows the model catalog (merged user/project layers over the
35    /// embedded `models.yaml`), annotating each entry with its source layer
36    /// (mirrors the `config_models_show` MCP tool).
37    Show(ShowCommand),
38}
39
40/// Show command options.
41#[derive(Parser)]
42pub struct ShowCommand {
43    /// Show only the embedded `models.yaml` verbatim, ignoring any
44    /// user/project overrides.
45    #[arg(long)]
46    pub embedded_only: bool,
47}
48
49impl ConfigCommand {
50    /// Executes the config command.
51    pub fn execute(self) -> Result<()> {
52        match self.command {
53            ConfigSubcommands::Models(models_cmd) => models_cmd.execute(),
54        }
55    }
56}
57
58impl ModelsCommand {
59    /// Executes the models command.
60    pub fn execute(self) -> Result<()> {
61        match self.command {
62            ModelsSubcommands::Show(show_cmd) => show_cmd.execute(),
63        }
64    }
65}
66
67impl ShowCommand {
68    /// Executes the show command.
69    pub fn execute(self) -> Result<()> {
70        if self.embedded_only {
71            print!("{MODELS_YAML}");
72            return Ok(());
73        }
74
75        let registry = get_model_registry();
76        let yaml = render_merged_yaml(registry.config())?;
77        print!("{yaml}");
78        Ok(())
79    }
80}
81
82/// Serialises the merged configuration with each model and provider entry
83/// carrying a `source: embedded|user|project|override` field. Returns the
84/// rendered YAML text.
85fn render_merged_yaml(config: &crate::claude::model_config::ModelConfiguration) -> Result<String> {
86    let yaml = serde_yaml::to_string(config)?;
87    Ok(prepend_layer_summary(&yaml, config))
88}
89
90fn prepend_layer_summary(
91    yaml: &str,
92    config: &crate::claude::model_config::ModelConfiguration,
93) -> String {
94    let mut counts: std::collections::BTreeMap<ModelSource, usize> =
95        std::collections::BTreeMap::new();
96    for spec in &config.models {
97        *counts.entry(spec.source).or_default() += 1;
98    }
99
100    let mut header = String::new();
101    header.push_str("# Merged model catalog (project > user > embedded).\n");
102    header.push_str("# Each entry's `source:` field indicates the layer that contributed it.\n");
103    header.push_str("# Models by source: ");
104    let parts: Vec<String> = counts.iter().map(|(s, n)| format!("{s}={n}")).collect();
105    if parts.is_empty() {
106        header.push_str("(none)");
107    } else {
108        header.push_str(&parts.join(", "));
109    }
110    header.push_str(".\n#\n");
111
112    let mut out = header;
113    out.push_str(yaml);
114    out
115}
116
117#[cfg(test)]
118#[allow(clippy::unwrap_used, clippy::expect_used)]
119mod tests {
120    use super::*;
121    use crate::claude::model_config::ModelRegistry;
122    use std::io::Write;
123    use std::path::Path;
124
125    fn write(dir: &Path, name: &str, contents: &str) -> std::path::PathBuf {
126        let path = dir.join(name);
127        std::fs::File::create(&path)
128            .unwrap()
129            .write_all(contents.as_bytes())
130            .unwrap();
131        path
132    }
133
134    #[test]
135    fn rendered_yaml_includes_source_for_each_entry() {
136        let dir = tempfile::tempdir().unwrap();
137        let user = write(
138            dir.path(),
139            "user.yaml",
140            r#"
141version: "1"
142models:
143  - provider: "claude"
144    model: "Custom"
145    api_identifier: "claude-custom-x"
146    max_output_tokens: 1
147    input_context: 1
148    generation: 1.0
149    tier: "flagship"
150"#,
151        );
152
153        let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
154        let yaml = render_merged_yaml(registry.config()).unwrap();
155
156        // Header summary mentions both layers.
157        assert!(yaml.contains("Merged model catalog"));
158        assert!(yaml.contains("embedded="));
159        assert!(yaml.contains("user="));
160
161        // Source field is present for the user-added entry…
162        assert!(yaml.contains("api_identifier: claude-custom-x"));
163        assert!(yaml.contains("source: user"));
164        // …and for embedded entries.
165        assert!(yaml.contains("source: embedded"));
166    }
167
168    #[test]
169    fn embedded_only_flag_round_trips_embedded_yaml() {
170        let cmd = ShowCommand {
171            embedded_only: true,
172        };
173        // execute() prints to stdout; we just confirm it does not error and
174        // that the underlying constant is what `--embedded-only` would emit.
175        cmd.execute().unwrap();
176        assert!(MODELS_YAML.contains("version: \"1\""));
177    }
178
179    #[test]
180    fn layer_summary_handles_empty_models() {
181        let config = crate::claude::model_config::ModelConfiguration {
182            version: Some("1".into()),
183            models: Vec::new(),
184            providers: std::collections::HashMap::new(),
185        };
186        let summary = prepend_layer_summary("", &config);
187        assert!(summary.contains("Models by source: (none)"));
188    }
189}