1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use zeroize::Zeroizing;
6
7use crate::credentials::{parse_keyring_reference, CredentialStore};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct InstanceConfig {
12 pub metadata: InstanceMetadata,
14
15 pub config: HashMap<String, ConfigValue>,
17
18 pub environment: HashMap<String, String>,
20
21 pub capabilities: Capabilities,
23}
24
25impl Default for InstanceConfig {
26 fn default() -> Self {
27 Self {
28 metadata: InstanceMetadata::default(),
29 config: HashMap::new(),
30 environment: HashMap::new(),
31 capabilities: Capabilities::default(),
32 }
33 }
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct InstanceMetadata {
38 pub skill_name: String,
39 pub skill_version: String,
40 pub instance_name: String,
41 pub created_at: chrono::DateTime<chrono::Utc>,
42 pub updated_at: chrono::DateTime<chrono::Utc>,
43}
44
45impl Default for InstanceMetadata {
46 fn default() -> Self {
47 let now = chrono::Utc::now();
48 Self {
49 skill_name: String::new(),
50 skill_version: String::new(),
51 instance_name: String::new(),
52 created_at: now,
53 updated_at: now,
54 }
55 }
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct ConfigValue {
60 pub value: String,
61 #[serde(default)]
62 pub secret: bool,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct Capabilities {
67 #[serde(default)]
69 pub allowed_paths: Vec<PathBuf>,
70
71 #[serde(default)]
73 pub network_access: bool,
74
75 #[serde(default = "default_max_concurrent")]
77 pub max_concurrent_requests: usize,
78}
79
80fn default_max_concurrent() -> usize {
81 10
82}
83
84impl Default for Capabilities {
85 fn default() -> Self {
86 Self {
87 allowed_paths: Vec::new(),
88 network_access: false,
89 max_concurrent_requests: default_max_concurrent(),
90 }
91 }
92}
93
94impl InstanceConfig {
95 pub fn load(path: impl AsRef<Path>) -> Result<Self> {
97 let contents = std::fs::read_to_string(path.as_ref())
98 .with_context(|| format!("Failed to read config file: {}", path.as_ref().display()))?;
99
100 let config: Self = toml::from_str(&contents)
101 .context("Failed to parse config file")?;
102
103 Ok(config)
104 }
105
106 pub fn save(&self, path: impl AsRef<Path>) -> Result<()> {
108 let contents = toml::to_string_pretty(self)
109 .context("Failed to serialize config")?;
110
111 std::fs::write(path.as_ref(), contents)
112 .with_context(|| format!("Failed to write config file: {}", path.as_ref().display()))?;
113
114 Ok(())
115 }
116
117 pub fn get_config(&self, key: &str) -> Option<String> {
120 self.config.get(key).and_then(|v| {
121 if v.secret {
122 None } else {
124 Some(v.value.clone())
125 }
126 })
127 }
128
129 pub fn get_secret_config(&self, key: &str) -> Result<Option<Zeroizing<String>>> {
132 if let Some(config_value) = self.config.get(key) {
133 if config_value.secret {
134 let (skill, instance, secret_key) = parse_keyring_reference(&config_value.value)?;
136
137 let credential_store = CredentialStore::new();
138 let secret = credential_store.get_credential(&skill, &instance, &secret_key)?;
139
140 return Ok(Some(secret));
141 }
142 }
143 Ok(None)
144 }
145
146 pub fn get_all_config(&self) -> Result<HashMap<String, Zeroizing<String>>> {
150 let mut result = HashMap::new();
151
152 for (key, value) in &self.config {
153 if value.secret {
154 if let Some(secret) = self.get_secret_config(key)? {
156 result.insert(key.clone(), secret);
157 }
158 } else {
159 result.insert(key.clone(), Zeroizing::new(value.value.clone()));
161 }
162 }
163
164 Ok(result)
165 }
166
167 pub fn set_config(&mut self, key: String, value: String, secret: bool) {
169 self.config.insert(key, ConfigValue { value, secret });
170 self.metadata.updated_at = chrono::Utc::now();
171 }
172
173 pub fn instance_dir(skill_name: &str, instance_name: &str) -> Result<PathBuf> {
175 let home = dirs::home_dir()
176 .context("Failed to get home directory")?;
177
178 Ok(home
179 .join(".skill-engine")
180 .join("instances")
181 .join(skill_name)
182 .join(instance_name))
183 }
184
185 pub fn config_path(skill_name: &str, instance_name: &str) -> Result<PathBuf> {
187 Ok(Self::instance_dir(skill_name, instance_name)?.join("config.toml"))
188 }
189
190 pub fn create_instance_dir(skill_name: &str, instance_name: &str) -> Result<PathBuf> {
192 let instance_dir = Self::instance_dir(skill_name, instance_name)?;
193 std::fs::create_dir_all(&instance_dir)
194 .with_context(|| format!("Failed to create instance directory: {}", instance_dir.display()))?;
195 Ok(instance_dir)
196 }
197}
198
199pub struct InstanceManager {
201 instances_root: PathBuf,
202 credential_store: CredentialStore,
203}
204
205impl InstanceManager {
206 pub fn new() -> Result<Self> {
208 let home = dirs::home_dir()
209 .context("Failed to get home directory")?;
210
211 let instances_root = home.join(".skill-engine").join("instances");
212 std::fs::create_dir_all(&instances_root)?;
213
214 Ok(Self {
215 instances_root,
216 credential_store: CredentialStore::new(),
217 })
218 }
219
220 pub fn create_instance(
222 &self,
223 skill_name: &str,
224 instance_name: &str,
225 config: InstanceConfig,
226 secrets: HashMap<String, String>,
227 ) -> Result<()> {
228 InstanceConfig::create_instance_dir(skill_name, instance_name)?;
230
231 let mut updated_config = config;
233 for (key, value) in secrets {
234 self.credential_store
236 .store_credential(skill_name, instance_name, &key, &value)?;
237
238 let keyring_ref =
240 format!("keyring://skill-engine/{}/{}/{}", skill_name, instance_name, key);
241 updated_config.config.insert(
242 key,
243 ConfigValue {
244 value: keyring_ref,
245 secret: true,
246 },
247 );
248 }
249
250 self.save_instance(skill_name, instance_name, &updated_config)?;
252
253 tracing::info!(
254 skill = %skill_name,
255 instance = %instance_name,
256 "Created instance"
257 );
258
259 Ok(())
260 }
261
262 pub fn list_instances(&self, skill_name: &str) -> Result<Vec<String>> {
264 let skill_dir = self.instances_root.join(skill_name);
265
266 if !skill_dir.exists() {
267 return Ok(Vec::new());
268 }
269
270 let mut instances = Vec::new();
271
272 for entry in std::fs::read_dir(&skill_dir)? {
273 let entry = entry?;
274 if entry.file_type()?.is_dir() {
275 if let Some(name) = entry.file_name().to_str() {
276 instances.push(name.to_string());
277 }
278 }
279 }
280
281 Ok(instances)
282 }
283
284 pub fn load_instance(&self, skill_name: &str, instance_name: &str) -> Result<InstanceConfig> {
286 let config_path = InstanceConfig::config_path(skill_name, instance_name)?;
287 InstanceConfig::load(config_path)
288 }
289
290 pub fn save_instance(&self, skill_name: &str, instance_name: &str, config: &InstanceConfig) -> Result<()> {
292 let config_path = InstanceConfig::config_path(skill_name, instance_name)?;
293 config.save(config_path)
294 }
295
296 pub fn delete_instance(&self, skill_name: &str, instance_name: &str) -> Result<()> {
298 if let Ok(config) = self.load_instance(skill_name, instance_name) {
300 for (_key, value) in &config.config {
302 if value.secret {
303 if let Ok((_, _, secret_key)) = parse_keyring_reference(&value.value) {
305 let _ = self
306 .credential_store
307 .delete_credential(skill_name, instance_name, &secret_key);
308 }
309 }
310 }
311 }
312
313 let instance_dir = InstanceConfig::instance_dir(skill_name, instance_name)?;
315 if instance_dir.exists() {
316 std::fs::remove_dir_all(&instance_dir)
317 .with_context(|| format!("Failed to delete instance directory: {}", instance_dir.display()))?;
318 }
319
320 tracing::info!(
321 skill = %skill_name,
322 instance = %instance_name,
323 "Deleted instance and credentials"
324 );
325
326 Ok(())
327 }
328
329 pub fn update_secret(
331 &self,
332 skill_name: &str,
333 instance_name: &str,
334 key: &str,
335 value: &str,
336 ) -> Result<()> {
337 self.credential_store
338 .store_credential(skill_name, instance_name, key, value)?;
339
340 tracing::debug!(
341 skill = %skill_name,
342 instance = %instance_name,
343 key = %key,
344 "Updated secret"
345 );
346
347 Ok(())
348 }
349}
350
351impl Default for InstanceManager {
352 fn default() -> Self {
353 Self::new().expect("Failed to create InstanceManager")
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360 use tempfile::TempDir;
361
362 #[test]
363 fn test_instance_config_serialization() {
364 let mut config = InstanceConfig::default();
365 config.metadata.skill_name = "test-skill".to_string();
366 config.metadata.instance_name = "test-instance".to_string();
367 config.set_config("key1".to_string(), "value1".to_string(), false);
368
369 let toml = toml::to_string(&config).unwrap();
370 let deserialized: InstanceConfig = toml::from_str(&toml).unwrap();
371
372 assert_eq!(deserialized.metadata.skill_name, "test-skill");
373 assert_eq!(deserialized.get_config("key1"), Some("value1".to_string()));
374 }
375
376 #[test]
377 fn test_config_value() {
378 let mut config = InstanceConfig::default();
379 config.set_config("test".to_string(), "value".to_string(), false);
380
381 assert_eq!(config.get_config("test"), Some("value".to_string()));
382 assert_eq!(config.get_config("nonexistent"), None);
383 }
384}