use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use zeroize::Zeroizing;
use crate::credentials::{parse_keyring_reference, CredentialStore};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstanceConfig {
pub metadata: InstanceMetadata,
pub config: HashMap<String, ConfigValue>,
pub environment: HashMap<String, String>,
pub capabilities: Capabilities,
}
impl Default for InstanceConfig {
fn default() -> Self {
Self {
metadata: InstanceMetadata::default(),
config: HashMap::new(),
environment: HashMap::new(),
capabilities: Capabilities::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstanceMetadata {
pub skill_name: String,
pub skill_version: String,
pub instance_name: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
impl Default for InstanceMetadata {
fn default() -> Self {
let now = chrono::Utc::now();
Self {
skill_name: String::new(),
skill_version: String::new(),
instance_name: String::new(),
created_at: now,
updated_at: now,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigValue {
pub value: String,
#[serde(default)]
pub secret: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Capabilities {
#[serde(default)]
pub allowed_paths: Vec<PathBuf>,
#[serde(default)]
pub network_access: bool,
#[serde(default = "default_max_concurrent")]
pub max_concurrent_requests: usize,
}
fn default_max_concurrent() -> usize {
10
}
impl Default for Capabilities {
fn default() -> Self {
Self {
allowed_paths: Vec::new(),
network_access: false,
max_concurrent_requests: default_max_concurrent(),
}
}
}
impl InstanceConfig {
pub fn load(path: impl AsRef<Path>) -> Result<Self> {
let contents = std::fs::read_to_string(path.as_ref())
.with_context(|| format!("Failed to read config file: {}", path.as_ref().display()))?;
let config: Self = toml::from_str(&contents)
.context("Failed to parse config file")?;
Ok(config)
}
pub fn save(&self, path: impl AsRef<Path>) -> Result<()> {
let contents = toml::to_string_pretty(self)
.context("Failed to serialize config")?;
std::fs::write(path.as_ref(), contents)
.with_context(|| format!("Failed to write config file: {}", path.as_ref().display()))?;
Ok(())
}
pub fn get_config(&self, key: &str) -> Option<String> {
self.config.get(key).and_then(|v| {
if v.secret {
None } else {
Some(v.value.clone())
}
})
}
pub fn get_secret_config(&self, key: &str) -> Result<Option<Zeroizing<String>>> {
if let Some(config_value) = self.config.get(key) {
if config_value.secret {
let (skill, instance, secret_key) = parse_keyring_reference(&config_value.value)?;
let credential_store = CredentialStore::new();
let secret = credential_store.get_credential(&skill, &instance, &secret_key)?;
return Ok(Some(secret));
}
}
Ok(None)
}
pub fn get_all_config(&self) -> Result<HashMap<String, Zeroizing<String>>> {
let mut result = HashMap::new();
for (key, value) in &self.config {
if value.secret {
if let Some(secret) = self.get_secret_config(key)? {
result.insert(key.clone(), secret);
}
} else {
result.insert(key.clone(), Zeroizing::new(value.value.clone()));
}
}
Ok(result)
}
pub fn set_config(&mut self, key: String, value: String, secret: bool) {
self.config.insert(key, ConfigValue { value, secret });
self.metadata.updated_at = chrono::Utc::now();
}
pub fn instance_dir(skill_name: &str, instance_name: &str) -> Result<PathBuf> {
let home = dirs::home_dir()
.context("Failed to get home directory")?;
Ok(home
.join(".skill-engine")
.join("instances")
.join(skill_name)
.join(instance_name))
}
pub fn config_path(skill_name: &str, instance_name: &str) -> Result<PathBuf> {
Ok(Self::instance_dir(skill_name, instance_name)?.join("config.toml"))
}
pub fn create_instance_dir(skill_name: &str, instance_name: &str) -> Result<PathBuf> {
let instance_dir = Self::instance_dir(skill_name, instance_name)?;
std::fs::create_dir_all(&instance_dir)
.with_context(|| format!("Failed to create instance directory: {}", instance_dir.display()))?;
Ok(instance_dir)
}
}
pub struct InstanceManager {
instances_root: PathBuf,
credential_store: CredentialStore,
}
impl InstanceManager {
pub fn new() -> Result<Self> {
let home = dirs::home_dir()
.context("Failed to get home directory")?;
let instances_root = home.join(".skill-engine").join("instances");
std::fs::create_dir_all(&instances_root)?;
Ok(Self {
instances_root,
credential_store: CredentialStore::new(),
})
}
pub fn create_instance(
&self,
skill_name: &str,
instance_name: &str,
config: InstanceConfig,
secrets: HashMap<String, String>,
) -> Result<()> {
InstanceConfig::create_instance_dir(skill_name, instance_name)?;
let mut updated_config = config;
for (key, value) in secrets {
self.credential_store
.store_credential(skill_name, instance_name, &key, &value)?;
let keyring_ref =
format!("keyring://skill-engine/{}/{}/{}", skill_name, instance_name, key);
updated_config.config.insert(
key,
ConfigValue {
value: keyring_ref,
secret: true,
},
);
}
self.save_instance(skill_name, instance_name, &updated_config)?;
tracing::info!(
skill = %skill_name,
instance = %instance_name,
"Created instance"
);
Ok(())
}
pub fn list_instances(&self, skill_name: &str) -> Result<Vec<String>> {
let skill_dir = self.instances_root.join(skill_name);
if !skill_dir.exists() {
return Ok(Vec::new());
}
let mut instances = Vec::new();
for entry in std::fs::read_dir(&skill_dir)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
if let Some(name) = entry.file_name().to_str() {
instances.push(name.to_string());
}
}
}
Ok(instances)
}
pub fn load_instance(&self, skill_name: &str, instance_name: &str) -> Result<InstanceConfig> {
let config_path = InstanceConfig::config_path(skill_name, instance_name)?;
InstanceConfig::load(config_path)
}
pub fn save_instance(&self, skill_name: &str, instance_name: &str, config: &InstanceConfig) -> Result<()> {
let config_path = InstanceConfig::config_path(skill_name, instance_name)?;
config.save(config_path)
}
pub fn delete_instance(&self, skill_name: &str, instance_name: &str) -> Result<()> {
if let Ok(config) = self.load_instance(skill_name, instance_name) {
for (_key, value) in &config.config {
if value.secret {
if let Ok((_, _, secret_key)) = parse_keyring_reference(&value.value) {
let _ = self
.credential_store
.delete_credential(skill_name, instance_name, &secret_key);
}
}
}
}
let instance_dir = InstanceConfig::instance_dir(skill_name, instance_name)?;
if instance_dir.exists() {
std::fs::remove_dir_all(&instance_dir)
.with_context(|| format!("Failed to delete instance directory: {}", instance_dir.display()))?;
}
tracing::info!(
skill = %skill_name,
instance = %instance_name,
"Deleted instance and credentials"
);
Ok(())
}
pub fn update_secret(
&self,
skill_name: &str,
instance_name: &str,
key: &str,
value: &str,
) -> Result<()> {
self.credential_store
.store_credential(skill_name, instance_name, key, value)?;
tracing::debug!(
skill = %skill_name,
instance = %instance_name,
key = %key,
"Updated secret"
);
Ok(())
}
}
impl Default for InstanceManager {
fn default() -> Self {
Self::new().expect("Failed to create InstanceManager")
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_instance_config_serialization() {
let mut config = InstanceConfig::default();
config.metadata.skill_name = "test-skill".to_string();
config.metadata.instance_name = "test-instance".to_string();
config.set_config("key1".to_string(), "value1".to_string(), false);
let toml = toml::to_string(&config).unwrap();
let deserialized: InstanceConfig = toml::from_str(&toml).unwrap();
assert_eq!(deserialized.metadata.skill_name, "test-skill");
assert_eq!(deserialized.get_config("key1"), Some("value1".to_string()));
}
#[test]
fn test_config_value() {
let mut config = InstanceConfig::default();
config.set_config("test".to_string(), "value".to_string(), false);
assert_eq!(config.get_config("test"), Some("value".to_string()));
assert_eq!(config.get_config("nonexistent"), None);
}
}