1pub mod scopes;
4
5use anyhow::Result;
6use clap::{Parser, Subcommand};
7
8use crate::claude::model_config::{get_model_registry, ModelSource, MODELS_YAML};
9
10#[derive(Parser)]
12pub struct ConfigCommand {
13 #[command(subcommand)]
15 pub command: ConfigSubcommands,
16}
17
18#[derive(Subcommand)]
20pub enum ConfigSubcommands {
21 Models(ModelsCommand),
23 Scopes(scopes::ScopesCommand),
25}
26
27#[derive(Parser)]
29pub struct ModelsCommand {
30 #[command(subcommand)]
32 pub command: ModelsSubcommands,
33}
34
35#[derive(Subcommand)]
37pub enum ModelsSubcommands {
38 Show(ShowCommand),
42}
43
44#[derive(Parser)]
46pub struct ShowCommand {
47 #[arg(long)]
50 pub embedded_only: bool,
51
52 #[command(flatten)]
54 pub models_yaml: crate::cli::ai_backend_args::ModelsYamlArg,
55}
56
57impl ConfigCommand {
58 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 pub fn execute(self) -> Result<()> {
71 match self.command {
72 ModelsSubcommands::Show(show_cmd) => show_cmd.execute(),
73 }
74 }
75}
76
77impl ShowCommand {
78 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
94fn 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 assert!(yaml.contains("Merged model catalog"));
170 assert!(yaml.contains("embedded="));
171 assert!(yaml.contains("user="));
172
173 assert!(yaml.contains("api_identifier: claude-custom-x"));
175 assert!(yaml.contains("source: user"));
176 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 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}