Skip to main content

omni_dev/cli/
config.rs

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