1use std::fs::{self, OpenOptions};
2use std::io::{self, Write};
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7#[cfg(unix)]
8use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
9
10pub const DEFAULT_BASE_URL: &str = "https://openrouter.ai/api/v1";
11pub const DEFAULT_API_KEY_ENV: &str = "OPENAI_API_KEY";
12pub const GENERATED_API_KEY_ENV: &str = "OPENROUTER_API_KEY";
13pub const DEFAULT_SYSTEM_PROMPT: &str = "You can access computer resources. Use the provided tools to achieve the user's requirements. When needed, use cmd to read a relevant skill's SKILL.md.";
14
15const GENERATED_CONFIG: &str = r#"system_prompt = "You can access computer resources. Use the provided tools to achieve the user's requirements. When needed, use cmd to read a relevant skill's SKILL.md."
16
17[llm]
18base_url = "https://openrouter.ai/api/v1"
19model = ""
20api_key_env = "OPENROUTER_API_KEY"
21# Optional reasoning effort sent as the OpenAI Chat Completions "reasoning_effort"
22# field, e.g. "low", "medium", "high". Omit or leave unset to send no effort.
23# Use a value your provider and model support; an unsupported value fails at runtime.
24# effort = "medium"
25"#;
26
27#[derive(Debug)]
28pub struct ConfigError(String);
29
30impl ConfigError {
31 fn new(message: impl Into<String>) -> Self {
32 Self(message.into())
33 }
34}
35
36impl std::fmt::Display for ConfigError {
37 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 formatter.write_str(&self.0)
39 }
40}
41
42impl std::error::Error for ConfigError {}
43
44impl From<io::Error> for ConfigError {
45 fn from(_error: io::Error) -> Self {
46 Self::new("configuration file error")
47 }
48}
49
50#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
51pub struct Config {
52 #[serde(default = "default_system_prompt")]
53 pub system_prompt: String,
54 #[serde(default)]
55 pub llm: LlmConfig,
56}
57
58#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
59pub struct LlmConfig {
60 #[serde(default = "default_base_url")]
61 pub base_url: String,
62 #[serde(default)]
63 pub model: String,
64 #[serde(default)]
65 pub api_key_env: Option<String>,
66 #[serde(default)]
67 pub effort: Option<String>,
68}
69
70#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
71pub struct LlmSettings {
72 pub base_url: String,
73 pub model: String,
74 pub api_key_env: String,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub effort: Option<String>,
77}
78
79impl Default for Config {
80 fn default() -> Self {
81 Self {
82 system_prompt: DEFAULT_SYSTEM_PROMPT.to_owned(),
83 llm: LlmConfig::default(),
84 }
85 }
86}
87
88impl Default for LlmConfig {
89 fn default() -> Self {
90 Self {
91 base_url: DEFAULT_BASE_URL.to_owned(),
92 model: String::new(),
93 api_key_env: None,
94 effort: None,
95 }
96 }
97}
98
99fn default_system_prompt() -> String {
100 DEFAULT_SYSTEM_PROMPT.to_owned()
101}
102
103fn default_base_url() -> String {
104 DEFAULT_BASE_URL.to_owned()
105}
106
107impl Config {
108 pub fn load_or_create(home: &Path) -> Result<Self, ConfigError> {
109 Self::ensure_exists(home)?;
110 Self::load_from_path(&config_path(home))
111 }
112
113 pub fn ensure_exists(home: &Path) -> Result<(), ConfigError> {
114 let path = config_path(home);
115 ensure_private_dir(&lucy_dir(home))?;
116 ensure_not_symlink(&path)?;
117
118 if !path.exists() && generated_config_contains_active_key() {
119 return Err(ConfigError::new("configuration bootstrap rejected"));
120 }
121
122 let mut options = OpenOptions::new();
123 options.write(true).create_new(true);
124 #[cfg(unix)]
125 options.mode(0o600);
126 match options.open(&path) {
127 Ok(mut file) => {
128 file.write_all(GENERATED_CONFIG.as_bytes())?;
129 file.flush()?;
130 ensure_private_file(&path)?;
131 Ok(())
132 }
133 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
134 ensure_private_file(&path)?;
135 Ok(())
136 }
137 Err(_error) => Err(ConfigError::new("unable to create config.toml")),
138 }
139 }
140
141 pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
142 ensure_not_symlink(path)
143 .map_err(|_error| ConfigError::new("unable to secure config.toml"))?;
144 let bytes = fs::read_to_string(path)
145 .map_err(|_error| ConfigError::new("unable to read config.toml"))?;
146 ensure_private_file(path)
147 .map_err(|_error| ConfigError::new("unable to secure config.toml"))?;
148 toml::from_str(&bytes)
149 .map_err(|_| ConfigError::new("unable to parse config.toml: invalid TOML"))
150 }
151
152 pub fn resolved_llm(&self) -> Result<LlmSettings, ConfigError> {
153 let base_url = self.llm.base_url.trim().to_owned();
154 if base_url.is_empty() {
155 return Err(ConfigError::new("llm.base_url must not be empty"));
156 }
157
158 let api_key_env = self
159 .llm
160 .api_key_env
161 .as_deref()
162 .unwrap_or(DEFAULT_API_KEY_ENV)
163 .trim()
164 .to_owned();
165 if api_key_env.is_empty() {
166 return Err(ConfigError::new("llm.api_key_env must not be empty"));
167 }
168
169 let effort = self.llm.effort.as_deref().map(str::trim).map(str::to_owned);
170
171 Ok(LlmSettings {
172 base_url,
173 model: self.llm.model.trim().to_owned(),
174 api_key_env,
175 effort,
176 })
177 }
178}
179
180pub fn config_path(home: &Path) -> PathBuf {
181 home.join(".lucy").join("config.toml")
182}
183
184pub fn lucy_dir(home: &Path) -> PathBuf {
185 home.join(".lucy")
186}
187
188fn generated_config_contains_active_key() -> bool {
189 std::env::var(GENERATED_API_KEY_ENV)
190 .ok()
191 .filter(|secret| !secret.is_empty())
192 .is_some_and(|secret| GENERATED_CONFIG.contains(&secret))
193}
194
195pub(crate) fn ensure_not_symlink(path: &Path) -> io::Result<()> {
196 match fs::symlink_metadata(path) {
197 Ok(metadata) if metadata.file_type().is_symlink() => Err(io::Error::new(
198 io::ErrorKind::InvalidInput,
199 "symlinks are not allowed for protected paths",
200 )),
201 Ok(_) => Ok(()),
202 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
203 Err(error) => Err(error),
204 }
205}
206
207pub(crate) fn ensure_private_dir(path: &Path) -> io::Result<()> {
208 ensure_not_symlink(path)?;
209 fs::create_dir_all(path)?;
210 let metadata = fs::symlink_metadata(path)?;
211 if metadata.file_type().is_symlink() || !metadata.is_dir() {
212 return Err(io::Error::new(
213 io::ErrorKind::InvalidInput,
214 "protected path is not a directory",
215 ));
216 }
217 #[cfg(unix)]
218 fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
219 Ok(())
220}
221
222pub(crate) fn ensure_private_file(path: &Path) -> io::Result<()> {
223 ensure_not_symlink(path)?;
224 #[cfg(unix)]
225 fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
226 #[cfg(not(unix))]
227 let _ = path;
228 Ok(())
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 #[cfg(unix)]
235 use std::os::unix::fs::{symlink, PermissionsExt};
236 use std::sync::atomic::{AtomicU64, Ordering};
237 use std::time::{SystemTime, UNIX_EPOCH};
238
239 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
240
241 fn temporary_home() -> PathBuf {
242 loop {
243 let stamp = SystemTime::now()
244 .duration_since(UNIX_EPOCH)
245 .expect("clock")
246 .as_nanos();
247 let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
248 let path = std::env::temp_dir().join(format!(
249 "lucy-config-{stamp}-{}-{counter}",
250 std::process::id()
251 ));
252 match fs::create_dir(&path) {
253 Ok(()) => return path,
254 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
255 Err(error) => panic!("temp home: {error}"),
256 }
257 }
258 }
259
260 #[test]
261 fn bootstraps_config_without_overwriting_existing_bytes() {
262 let home = temporary_home();
263 let first = Config::load_or_create(&home).expect("create config");
264 assert_eq!(first.llm.model, "");
265 assert_eq!(first.llm.base_url, DEFAULT_BASE_URL);
266 assert_eq!(
267 first.llm.api_key_env.as_deref(),
268 Some(GENERATED_API_KEY_ENV)
269 );
270
271 let path = config_path(&home);
272 let generated = fs::read(&path).expect("generated bytes");
273 #[cfg(unix)]
274 assert_eq!(
275 fs::metadata(&path)
276 .expect("config metadata")
277 .permissions()
278 .mode()
279 & 0o777,
280 0o600
281 );
282 #[cfg(unix)]
283 assert_eq!(
284 fs::metadata(lucy_dir(&home))
285 .expect("Lucy directory metadata")
286 .permissions()
287 .mode()
288 & 0o777,
289 0o700
290 );
291 let custom = b"system_prompt = \"custom\"\n[llm]\nmodel = \"local\"\n";
292 fs::write(&path, custom).expect("custom config");
293 let loaded = Config::load_or_create(&home).expect("load custom config");
294 assert_eq!(loaded.system_prompt, "custom");
295 assert_eq!(loaded.llm.model, "local");
296 assert_ne!(generated, custom);
297 assert_eq!(fs::read(path).expect("bytes after load"), custom);
298
299 fs::remove_dir_all(home).expect("remove temp home");
300 }
301
302 #[cfg(unix)]
303 #[test]
304 fn rejects_symlinked_config_files_and_directories() {
305 let home = temporary_home();
306 let lucy = home.join(".lucy");
307 fs::create_dir(&lucy).expect("Lucy directory");
308 let target = home.join("config-target.toml");
309 fs::write(&target, "system_prompt = \"target\"\n").expect("target config");
310 let path = config_path(&home);
311 symlink(&target, &path).expect("config symlink");
312 assert!(Config::load_or_create(&home).is_err());
313 assert!(Config::load_from_path(&path).is_err());
314 fs::remove_file(path).expect("remove config symlink");
315 fs::remove_dir(lucy).expect("remove Lucy directory");
316 fs::remove_file(target).expect("remove target config");
317 fs::remove_dir(&home).expect("remove temp home");
318
319 let home = temporary_home();
320 let target = home.join("lucy-target");
321 fs::create_dir(&target).expect("target directory");
322 symlink(&target, home.join(".lucy")).expect("Lucy directory symlink");
323 assert!(Config::ensure_exists(&home).is_err());
324 fs::remove_file(home.join(".lucy")).expect("remove Lucy directory symlink");
325 fs::remove_dir(target).expect("remove target directory");
326 fs::remove_dir(home).expect("remove temp home");
327 }
328
329 #[test]
330 fn malformed_toml_error_does_not_include_source_details() {
331 let home = temporary_home();
332 let path = config_path(&home);
333 fs::create_dir_all(path.parent().expect("config parent")).expect("config parent");
334 fs::write(
335 &path,
336 "system_prompt = \"provider-secret\n[llm]\nmodel = [\n",
337 )
338 .expect("malformed config");
339
340 let error = Config::load_from_path(&path).expect_err("malformed TOML");
341 let message = error.to_string();
342 assert!(message.contains("invalid TOML"));
343 assert!(!message.contains("provider-secret"));
344 assert!(!message.contains("system_prompt"));
345 assert!(!message.contains(&path.display().to_string()));
346 fs::remove_dir_all(home).expect("remove temp home");
347 }
348
349 #[test]
350 fn omitted_api_key_environment_uses_openai_default() {
351 let config = Config {
352 system_prompt: "prompt".to_owned(),
353 llm: LlmConfig {
354 base_url: "http://localhost".to_owned(),
355 model: "model".to_owned(),
356 api_key_env: None,
357 effort: None,
358 },
359 };
360 assert_eq!(
361 config.resolved_llm().expect("settings").api_key_env,
362 DEFAULT_API_KEY_ENV
363 );
364 }
365
366 #[test]
367 fn resolved_effort_passes_through_and_trims() {
368 let config = |effort: Option<&str>| Config {
369 system_prompt: "prompt".to_owned(),
370 llm: LlmConfig {
371 base_url: "http://localhost".to_owned(),
372 model: "model".to_owned(),
373 api_key_env: Some("LUCY_KEY".to_owned()),
374 effort: effort.map(str::to_owned),
375 },
376 };
377 assert_eq!(config(None).resolved_llm().expect("none").effort, None);
378 assert_eq!(
379 config(Some("high"))
380 .resolved_llm()
381 .expect("set")
382 .effort
383 .as_deref(),
384 Some("high")
385 );
386 assert_eq!(
387 config(Some(" medium "))
388 .resolved_llm()
389 .expect("trim")
390 .effort
391 .as_deref(),
392 Some("medium")
393 );
394 }
395}