1use std::{collections::BTreeMap, io::Write, path::PathBuf};
6
7use anyhow::{Context, Result, bail};
8use scv_client::Layout;
9
10use super::{
11 Config, ConfigOverrides, ProviderConfig,
12 validate::{validate_project_keys, validate_project_not_weaker},
13};
14
15const MAX_CONFIG_BYTES: u64 = 1024 * 1024;
16
17impl Config {
18 pub fn init_user_config(layout: &Layout) -> Result<PathBuf> {
21 let path = layout.config();
22 if let Some(parent) = path.parent() {
23 std::fs::create_dir_all(parent).context("create config directory")?;
24 ensure_private_dir(parent)?;
25 }
26 let content = "[provider]\nactive = \"openai\"\n\n[providers.openai]\nkind = \"openai-compatible\"\nmodel = \"gpt-4.1-mini\"\nbase_url = \"https://api.openai.com/v1\"\napi_key_env = \"OPENAI_API_KEY\"\n";
27 if !path.exists() {
28 let parent = path
29 .parent()
30 .ok_or_else(|| anyhow::anyhow!("configuration path has no parent"))?;
31 let mut temporary = tempfile::NamedTempFile::new_in(parent)
32 .context("create temporary example configuration")?;
33 #[cfg(unix)]
34 {
35 use std::os::unix::fs::PermissionsExt;
36 temporary
37 .as_file()
38 .set_permissions(std::fs::Permissions::from_mode(0o600))
39 .context("secure temporary configuration")?;
40 }
41 temporary
42 .write_all(content.as_bytes())
43 .context("write example configuration")?;
44 temporary
45 .as_file()
46 .sync_all()
47 .context("sync example configuration")?;
48 match temporary.persist(&path) {
49 Ok(_) => {}
50 Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => {}
51 Err(error) => return Err(error.error).context("install example configuration"),
52 }
53 }
54 Ok(path)
55 }
56 pub(crate) fn active_provider(&self) -> Result<ProviderConfig> {
57 if let Some(name) = self
58 .provider_active
59 .as_deref()
60 .or(self.provider.active.as_deref())
61 {
62 return self
63 .providers
64 .get(name)
65 .cloned()
66 .ok_or_else(|| anyhow::anyhow!("active provider profile {name:?} was not found"));
67 }
68 Ok(self.provider.clone())
69 }
70}
71
72impl Config {
73 pub fn load(
76 layout: &Layout,
77 workspace: &std::path::Path,
78 overrides: ConfigOverrides,
79 ) -> Result<Self> {
80 Self::load_layers(layout, Some(workspace), overrides)
81 }
82
83 pub fn load_user(layout: &Layout, overrides: ConfigOverrides) -> Result<Self> {
86 Self::load_layers(layout, None, overrides)
87 }
88
89 fn load_layers(
90 layout: &Layout,
91 workspace: Option<&std::path::Path>,
92 overrides: ConfigOverrides,
93 ) -> Result<Self> {
94 let instance_home = layout.home().to_owned();
95 std::fs::create_dir_all(&instance_home).context("create SCV instance home")?;
96 ensure_private_dir(&instance_home)?;
97 let mut value: toml::Value = toml::from_str(
98 &toml::to_string(&Self::default()).context("serialize default configuration")?,
99 )?;
100
101 let user_path = layout.config();
102 if user_path.is_file() {
103 #[cfg(unix)]
104 {
105 use std::os::unix::fs::PermissionsExt;
106 if std::fs::metadata(&user_path)?.permissions().mode() & 0o077 != 0 {
107 bail!("user configuration is readable by group or others; run chmod 600");
108 }
109 }
110 merge(&mut value, read_layer(&user_path)?);
111 }
112 let user_baseline: Self = value
113 .clone()
114 .try_into()
115 .context("parse user configuration")?;
116
117 if let Some(workspace) = workspace {
118 let project_path = workspace.join(".scv/config.toml");
119 let user_file = std::fs::canonicalize(layout.config()).ok();
123 if project_path.is_file() {
124 let canonical_project = std::fs::canonicalize(&project_path)
125 .with_context(|| format!("resolve configuration {}", project_path.display()))?;
126 if user_file.as_ref() != Some(&canonical_project) {
127 if !canonical_project.starts_with(workspace) {
128 bail!("project configuration escaped workspace");
129 }
130 let project = read_layer(&canonical_project)?;
131 validate_project_keys(&project)?;
132 let mut candidate_value = value.clone();
133 merge(&mut candidate_value, project);
134 let candidate: Self = candidate_value
135 .clone()
136 .try_into()
137 .context("parse project configuration")?;
138 validate_project_not_weaker(&user_baseline, &candidate)?;
139 value = candidate_value;
140 }
141 }
142 }
143
144 if let Some(path) = &overrides.config_file {
145 #[cfg(unix)]
146 {
147 use std::os::unix::fs::PermissionsExt;
148 if std::fs::metadata(path)?.permissions().mode() & 0o077 != 0 {
149 bail!("explicit configuration is readable by group or others; run chmod 600");
150 }
151 }
152 let explicit = read_layer(path)?;
153 if explicit.get("channels").is_some() {
154 bail!(
155 "{} cannot set [channels]; channel accounts belong in the instance's config.toml",
156 path.display()
157 );
158 }
159 merge(&mut value, explicit);
160 }
161 let mut config: Self = value.try_into().context("parse merged configuration")?;
162 if let Some(name) = overrides.provider.as_deref() {
163 config.provider_active = Some(name.to_owned());
164 }
165 let selected = config.active_provider()?;
166 config.provider = selected;
167 if let Ok(model) = std::env::var("SCV_MODEL") {
168 config.provider.model = model;
169 }
170 if let Ok(base_url) = std::env::var("SCV_BASE_URL") {
171 config.provider.base_url = base_url;
172 }
173 if let Ok(api_key_env) = std::env::var("SCV_API_KEY_ENV") {
174 config.provider.api_key_env = Some(api_key_env);
175 }
176 if let Some(model) = overrides.model {
177 config.provider.model = model;
178 }
179 if let Some(base_url) = overrides.base_url {
180 config.provider.base_url = base_url;
181 }
182 if let Some(policy) = overrides.approval_policy {
183 config.tools.approval_policy = policy;
184 }
185 if config.skills.user_dir == std::path::Path::new("~/.scv/skills") {
186 config.skills.user_dir = layout.skills();
187 }
188 config.skills.user_dir = expand_home(&config.skills.user_dir);
189 if let Some(archive) = &config.history.archive_dir {
190 config.history.archive_dir = Some(expand_home(archive));
191 }
192 config.instance_home = instance_home;
193 config.validate()?;
194 Ok(config)
195 }
196
197 pub fn settings_with_origins(
202 layout: &Layout,
203 workspace: Option<&std::path::Path>,
204 overrides: &ConfigOverrides,
205 ) -> Result<Vec<Setting>> {
206 let mut settings: BTreeMap<String, (toml::Value, String)> = BTreeMap::new();
207 let mut apply = |value: &toml::Value, origin: &str| {
208 flatten(value, String::new(), &mut |key, value| {
209 settings.insert(key, (value.clone(), origin.to_owned()));
210 });
211 };
212 let defaults: toml::Value = toml::from_str(
213 &toml::to_string(&Self::default()).context("serialize default configuration")?,
214 )?;
215 apply(&defaults, "default");
216 let mut merged = defaults;
217 let user = Some(layout.config()).filter(|path| path.is_file());
218 if let Some(path) = &user {
219 let layer = read_layer(path)?;
220 apply(&layer, "config.toml");
221 merge(&mut merged, layer);
222 }
223 if let Some(workspace) = workspace {
224 let project = workspace.join(".scv/config.toml");
225 let user_file = user
226 .as_ref()
227 .and_then(|path| std::fs::canonicalize(path).ok());
228 if project.is_file() && std::fs::canonicalize(&project).ok() != user_file {
229 let layer = read_layer(&project)?;
230 apply(&layer, "project .scv/config.toml");
231 merge(&mut merged, layer);
232 }
233 }
234 if let Some(path) = &overrides.config_file {
235 let layer = read_layer(path)?;
236 apply(&layer, "SCV_CONFIG");
237 merge(&mut merged, layer);
238 }
239 let active = overrides.provider.clone().or_else(|| {
242 merged
243 .get("provider")?
244 .get("active")?
245 .as_str()
246 .map(ToOwned::to_owned)
247 });
248 let has_profiles = merged
249 .get("providers")
250 .and_then(toml::Value::as_table)
251 .is_some_and(|profiles| !profiles.is_empty());
252 let prefix = match active {
253 Some(name) if has_profiles => format!("providers.{name}"),
254 _ => "provider".into(),
255 };
256 let mut set = |key: String, value: String, origin: &str| {
257 settings.insert(key, (toml::Value::String(value), origin.to_owned()));
258 };
259 if let Some(name) = &overrides.provider {
260 set("provider.active".into(), name.clone(), "--provider flag");
261 }
262 for (field, variable) in [
263 ("model", "SCV_MODEL"),
264 ("base_url", "SCV_BASE_URL"),
265 ("api_key_env", "SCV_API_KEY_ENV"),
266 ] {
267 if let Ok(value) = std::env::var(variable) {
268 set(
269 format!("{prefix}.{field}"),
270 value,
271 &format!("env {variable}"),
272 );
273 }
274 }
275 for (field, value, flag) in [
276 ("model", &overrides.model, "--model flag"),
277 ("base_url", &overrides.base_url, "--base-url flag"),
278 ] {
279 if let Some(value) = value {
280 set(format!("{prefix}.{field}"), value.clone(), flag);
281 }
282 }
283 if let Some(policy) = overrides.approval_policy {
284 let value = toml::Value::try_from(policy).context("serialize approval policy")?;
285 settings.insert(
286 "tools.approval_policy".into(),
287 (value, "--approval-policy flag".into()),
288 );
289 }
290 Ok(settings
291 .into_iter()
292 .filter(|(key, _)| !key.starts_with("channels."))
293 .map(|(key, (value, origin))| Setting {
294 value: if is_secret_key(&key) {
295 "<hidden>".into()
296 } else {
297 value.to_string()
298 },
299 key,
300 origin,
301 })
302 .collect())
303 }
304}
305
306pub(super) fn ensure_private_dir(path: &std::path::Path) -> Result<()> {
307 #[cfg(unix)]
308 {
309 use std::os::unix::fs::PermissionsExt;
310 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
311 .with_context(|| format!("secure directory {}", path.display()))?;
312 }
313 Ok(())
314}
315
316pub fn read_layer(path: &std::path::Path) -> Result<toml::Value> {
319 let size = std::fs::metadata(path)
320 .with_context(|| format!("stat configuration {}", path.display()))?
321 .len();
322 if size > MAX_CONFIG_BYTES {
323 bail!("configuration {} exceeds 1 MiB", path.display());
324 }
325 let content = std::fs::read_to_string(path)
326 .with_context(|| format!("read configuration {}", path.display()))?;
327 toml::from_str(&content).map_err(|error: toml::de::Error| {
330 let line = error.span().map_or_else(String::new, |span| {
331 format!(
332 " line {}",
333 content[..span.start.min(content.len())]
334 .matches('\n')
335 .count()
336 + 1
337 )
338 });
339 anyhow::anyhow!(
340 "parse configuration {}{line}: {}",
341 path.display(),
342 error.message()
343 )
344 })
345}
346
347pub(super) fn merge(base: &mut toml::Value, overlay: toml::Value) {
348 match (base, overlay) {
349 (toml::Value::Table(base), toml::Value::Table(overlay)) => {
350 for (key, value) in overlay {
351 match base.get_mut(&key) {
352 Some(existing) => merge(existing, value),
353 None => {
354 base.insert(key, value);
355 }
356 }
357 }
358 }
359 (base, overlay) => *base = overlay,
360 }
361}
362
363#[derive(Debug, Clone, PartialEq, Eq)]
365pub struct Setting {
366 pub key: String,
368 pub value: String,
370 pub origin: String,
373}
374
375fn flatten(value: &toml::Value, prefix: String, visit: &mut impl FnMut(String, &toml::Value)) {
377 match value {
378 toml::Value::Table(table) => {
379 for (key, value) in table {
380 let key = if prefix.is_empty() {
381 key.clone()
382 } else {
383 format!("{prefix}.{key}")
384 };
385 flatten(value, key, visit);
386 }
387 }
388 leaf => visit(prefix, leaf),
389 }
390}
391
392pub(super) fn is_secret_key(key: &str) -> bool {
395 let last = key.rsplit('.').next().unwrap_or(key);
396 last == "api_key"
397 || last.ends_with("_api_key")
398 || last.contains("secret")
399 || last.contains("password")
400 || key.split('.').any(|segment| segment == "headers")
401}
402
403fn expand_home(path: &std::path::Path) -> PathBuf {
404 let value = path.to_string_lossy();
405 if value == "~" {
406 return dirs::home_dir().unwrap_or_else(|| path.to_path_buf());
407 }
408 if let Some(rest) = value.strip_prefix("~/")
409 && let Some(home) = dirs::home_dir()
410 {
411 return home.join(rest);
412 }
413 path.to_path_buf()
414}