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 let profile =
63 self.providers.get(name).cloned().ok_or_else(|| {
64 anyhow::anyhow!("active provider profile {name:?} was not found")
65 })?;
66 if self.provider.reasoning_effort.is_some() {
69 bail!(
70 "provider.reasoning_effort has no effect while profile {name:?} is \
71 active; set it in [providers.{name}]"
72 );
73 }
74 return Ok(profile);
75 }
76 Ok(self.provider.clone())
77 }
78}
79
80impl Config {
81 pub fn load(
84 layout: &Layout,
85 workspace: &std::path::Path,
86 overrides: ConfigOverrides,
87 ) -> Result<Self> {
88 Self::load_layers(layout, Some(workspace), overrides)
89 }
90
91 pub fn load_user(layout: &Layout, overrides: ConfigOverrides) -> Result<Self> {
94 Self::load_layers(layout, None, overrides)
95 }
96
97 fn load_layers(
98 layout: &Layout,
99 workspace: Option<&std::path::Path>,
100 overrides: ConfigOverrides,
101 ) -> Result<Self> {
102 let instance_home = layout.home().to_owned();
103 std::fs::create_dir_all(&instance_home).context("create SCV instance home")?;
104 ensure_private_dir(&instance_home)?;
105 let mut value: toml::Value = toml::from_str(
106 &toml::to_string(&Self::default()).context("serialize default configuration")?,
107 )?;
108
109 let user_path = layout.config();
110 if user_path.is_file() {
111 #[cfg(unix)]
112 {
113 use std::os::unix::fs::PermissionsExt;
114 if std::fs::metadata(&user_path)?.permissions().mode() & 0o077 != 0 {
115 bail!("user configuration is readable by group or others; run chmod 600");
116 }
117 }
118 merge(&mut value, read_layer(&user_path)?);
119 }
120 let user_baseline: Self = value
121 .clone()
122 .try_into()
123 .context("parse user configuration")?;
124
125 if let Some(workspace) = workspace {
126 let project_path = workspace.join(".scv/config.toml");
127 let user_file = std::fs::canonicalize(layout.config()).ok();
131 if project_path.is_file() {
132 let canonical_project = std::fs::canonicalize(&project_path)
133 .with_context(|| format!("resolve configuration {}", project_path.display()))?;
134 if user_file.as_ref() != Some(&canonical_project) {
135 if !canonical_project.starts_with(workspace) {
136 bail!("project configuration escaped workspace");
137 }
138 let project = read_layer(&canonical_project)?;
139 validate_project_keys(&project)?;
140 let mut candidate_value = value.clone();
141 merge(&mut candidate_value, project);
142 let candidate: Self = candidate_value
143 .clone()
144 .try_into()
145 .context("parse project configuration")?;
146 validate_project_not_weaker(&user_baseline, &candidate)?;
147 value = candidate_value;
148 }
149 }
150 }
151
152 if let Some(path) = &overrides.config_file {
153 #[cfg(unix)]
154 {
155 use std::os::unix::fs::PermissionsExt;
156 if std::fs::metadata(path)?.permissions().mode() & 0o077 != 0 {
157 bail!("explicit configuration is readable by group or others; run chmod 600");
158 }
159 }
160 let explicit = read_layer(path)?;
161 if explicit.get("channels").is_some() {
162 bail!(
163 "{} cannot set [channels]; channel accounts belong in the instance's config.toml",
164 path.display()
165 );
166 }
167 merge(&mut value, explicit);
168 }
169 let mut config: Self = value.try_into().context("parse merged configuration")?;
170 if let Some(name) = overrides.provider.as_deref() {
171 config.provider_active = Some(name.to_owned());
172 }
173 let selected = config.active_provider()?;
174 config.provider = selected;
175 if let Ok(model) = std::env::var("SCV_MODEL") {
176 config.provider.model = model;
177 }
178 if let Ok(base_url) = std::env::var("SCV_BASE_URL") {
179 config.provider.base_url = base_url;
180 }
181 if let Ok(api_key_env) = std::env::var("SCV_API_KEY_ENV") {
182 config.provider.api_key_env = Some(api_key_env);
183 }
184 if let Some(model) = overrides.model {
185 config.provider.model = model;
186 }
187 if let Some(base_url) = overrides.base_url {
188 config.provider.base_url = base_url;
189 }
190 if let Some(policy) = overrides.approval_policy {
191 config.tools.approval_policy = policy;
192 }
193 if config.skills.user_dir == std::path::Path::new("~/.scv/skills") {
194 config.skills.user_dir = layout.skills();
195 }
196 config.skills.user_dir = expand_home(&config.skills.user_dir);
197 if let Some(archive) = &config.history.archive_dir {
198 config.history.archive_dir = Some(expand_home(archive));
199 }
200 config.instance_home = instance_home;
201 config.validate()?;
202 Ok(config)
203 }
204
205 pub fn settings_with_origins(
210 layout: &Layout,
211 workspace: Option<&std::path::Path>,
212 overrides: &ConfigOverrides,
213 ) -> Result<Vec<Setting>> {
214 let mut settings: BTreeMap<String, (toml::Value, String)> = BTreeMap::new();
215 let mut apply = |value: &toml::Value, origin: &str| {
216 flatten(value, String::new(), &mut |key, value| {
217 settings.insert(key, (value.clone(), origin.to_owned()));
218 });
219 };
220 let defaults: toml::Value = toml::from_str(
221 &toml::to_string(&Self::default()).context("serialize default configuration")?,
222 )?;
223 apply(&defaults, "default");
224 let mut merged = defaults;
225 let user = Some(layout.config()).filter(|path| path.is_file());
226 if let Some(path) = &user {
227 let layer = read_layer(path)?;
228 apply(&layer, "config.toml");
229 merge(&mut merged, layer);
230 }
231 if let Some(workspace) = workspace {
232 let project = workspace.join(".scv/config.toml");
233 let user_file = user
234 .as_ref()
235 .and_then(|path| std::fs::canonicalize(path).ok());
236 if project.is_file() && std::fs::canonicalize(&project).ok() != user_file {
237 let layer = read_layer(&project)?;
238 apply(&layer, "project .scv/config.toml");
239 merge(&mut merged, layer);
240 }
241 }
242 if let Some(path) = &overrides.config_file {
243 let layer = read_layer(path)?;
244 apply(&layer, "SCV_CONFIG");
245 merge(&mut merged, layer);
246 }
247 let active = overrides.provider.clone().or_else(|| {
250 merged
251 .get("provider")?
252 .get("active")?
253 .as_str()
254 .map(ToOwned::to_owned)
255 });
256 let has_profiles = merged
257 .get("providers")
258 .and_then(toml::Value::as_table)
259 .is_some_and(|profiles| !profiles.is_empty());
260 let prefix = match active {
261 Some(name) if has_profiles => format!("providers.{name}"),
262 _ => "provider".into(),
263 };
264 let mut set = |key: String, value: String, origin: &str| {
265 settings.insert(key, (toml::Value::String(value), origin.to_owned()));
266 };
267 if let Some(name) = &overrides.provider {
268 set("provider.active".into(), name.clone(), "--provider flag");
269 }
270 for (field, variable) in [
271 ("model", "SCV_MODEL"),
272 ("base_url", "SCV_BASE_URL"),
273 ("api_key_env", "SCV_API_KEY_ENV"),
274 ] {
275 if let Ok(value) = std::env::var(variable) {
276 set(
277 format!("{prefix}.{field}"),
278 value,
279 &format!("env {variable}"),
280 );
281 }
282 }
283 for (field, value, flag) in [
284 ("model", &overrides.model, "--model flag"),
285 ("base_url", &overrides.base_url, "--base-url flag"),
286 ] {
287 if let Some(value) = value {
288 set(format!("{prefix}.{field}"), value.clone(), flag);
289 }
290 }
291 if let Some(policy) = overrides.approval_policy {
292 let value = toml::Value::try_from(policy).context("serialize approval policy")?;
293 settings.insert(
294 "tools.approval_policy".into(),
295 (value, "--approval-policy flag".into()),
296 );
297 }
298 Ok(settings
299 .into_iter()
300 .filter(|(key, _)| !key.starts_with("channels."))
301 .map(|(key, (value, origin))| Setting {
302 value: if is_secret_key(&key) {
303 "<hidden>".into()
304 } else {
305 value.to_string()
306 },
307 key,
308 origin,
309 })
310 .collect())
311 }
312}
313
314pub(super) fn ensure_private_dir(path: &std::path::Path) -> Result<()> {
315 #[cfg(unix)]
316 {
317 use std::os::unix::fs::PermissionsExt;
318 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
319 .with_context(|| format!("secure directory {}", path.display()))?;
320 }
321 Ok(())
322}
323
324pub fn read_layer(path: &std::path::Path) -> Result<toml::Value> {
327 let size = std::fs::metadata(path)
328 .with_context(|| format!("stat configuration {}", path.display()))?
329 .len();
330 if size > MAX_CONFIG_BYTES {
331 bail!("configuration {} exceeds 1 MiB", path.display());
332 }
333 let content = std::fs::read_to_string(path)
334 .with_context(|| format!("read configuration {}", path.display()))?;
335 toml::from_str(&content).map_err(|error: toml::de::Error| {
338 let line = error.span().map_or_else(String::new, |span| {
339 format!(
340 " line {}",
341 content[..span.start.min(content.len())]
342 .matches('\n')
343 .count()
344 + 1
345 )
346 });
347 anyhow::anyhow!(
348 "parse configuration {}{line}: {}",
349 path.display(),
350 error.message()
351 )
352 })
353}
354
355pub(super) fn merge(base: &mut toml::Value, overlay: toml::Value) {
356 match (base, overlay) {
357 (toml::Value::Table(base), toml::Value::Table(overlay)) => {
358 for (key, value) in overlay {
359 match base.get_mut(&key) {
360 Some(existing) => merge(existing, value),
361 None => {
362 base.insert(key, value);
363 }
364 }
365 }
366 }
367 (base, overlay) => *base = overlay,
368 }
369}
370
371#[derive(Debug, Clone, PartialEq, Eq)]
373pub struct Setting {
374 pub key: String,
376 pub value: String,
378 pub origin: String,
381}
382
383fn flatten(value: &toml::Value, prefix: String, visit: &mut impl FnMut(String, &toml::Value)) {
385 match value {
386 toml::Value::Table(table) => {
387 for (key, value) in table {
388 let key = if prefix.is_empty() {
389 key.clone()
390 } else {
391 format!("{prefix}.{key}")
392 };
393 flatten(value, key, visit);
394 }
395 }
396 leaf => visit(prefix, leaf),
397 }
398}
399
400pub(super) fn is_secret_key(key: &str) -> bool {
403 let last = key.rsplit('.').next().unwrap_or(key);
404 last == "api_key"
405 || last.ends_with("_api_key")
406 || last.contains("secret")
407 || last.contains("password")
408 || key.split('.').any(|segment| segment == "headers")
409}
410
411fn expand_home(path: &std::path::Path) -> PathBuf {
412 let value = path.to_string_lossy();
413 if value == "~" {
414 return dirs::home_dir().unwrap_or_else(|| path.to_path_buf());
415 }
416 if let Some(rest) = value.strip_prefix("~/")
417 && let Some(home) = dirs::home_dir()
418 {
419 return home.join(rest);
420 }
421 path.to_path_buf()
422}