use std::path::{Path, PathBuf};
use crate::config::{auth, models, settings};
#[derive(Clone, Debug, Default)]
pub struct ImportReport {
pub imported_keys: Vec<String>,
pub imported_models: Vec<String>,
pub imported_skills: Vec<String>,
pub imported_agents: Vec<String>,
pub skipped: Vec<String>,
pub warnings: Vec<String>,
}
pub fn import_all(saorsa_dir: &Path) -> crate::error::Result<ImportReport> {
let home = dirs::home_dir().ok_or(crate::error::SaorsaAgentError::HomeDirectory)?;
let mut report = ImportReport::default();
let pi_dir = home.join(".pi").join("agent");
let claude_dir = home.join(".claude");
let (keys, warnings) = import_pi_auth(&pi_dir, saorsa_dir);
report.imported_keys.extend(keys);
report.warnings.extend(warnings);
let (model_ids, warnings) = import_pi_models(&pi_dir, saorsa_dir);
report.imported_models.extend(model_ids);
report.warnings.extend(warnings);
let (skipped, warnings) = import_pi_settings(&pi_dir, saorsa_dir);
report.skipped.extend(skipped);
report.warnings.extend(warnings);
let mut skill_sources = Vec::new();
let pi_skills = pi_dir.join("skills");
if pi_skills.is_dir() {
skill_sources.push(pi_skills);
}
let claude_skills = claude_dir.join("skills");
if claude_skills.is_dir() {
skill_sources.push(claude_skills);
}
let target_skills = saorsa_dir.join("skills");
let (imported, skipped, warnings) = import_skills(&skill_sources, &target_skills);
report.imported_skills.extend(imported);
report.skipped.extend(skipped);
report.warnings.extend(warnings);
let mut agent_sources = Vec::new();
let claude_agents = claude_dir.join("agents");
if claude_agents.is_dir() {
agent_sources.push(claude_agents);
}
let target_agents = saorsa_dir.join("agents");
let (imported, skipped, warnings) = import_agents(&agent_sources, &target_agents);
report.imported_agents.extend(imported);
report.skipped.extend(skipped);
report.warnings.extend(warnings);
Ok(report)
}
fn import_pi_auth(pi_dir: &Path, saorsa_dir: &Path) -> (Vec<String>, Vec<String>) {
let mut imported = Vec::new();
let mut warnings = Vec::new();
let source = pi_dir.join("auth.json");
if !source.exists() {
return (imported, warnings);
}
let pi_auth = match auth::load(&source) {
Ok(cfg) => cfg,
Err(e) => {
warnings.push(format!("failed to load {}: {e}", source.display()));
return (imported, warnings);
}
};
let target = saorsa_dir.join("auth.json");
let mut saorsa_auth = match auth::load(&target) {
Ok(cfg) => cfg,
Err(e) => {
warnings.push(format!("failed to load {}: {e}", target.display()));
return (imported, warnings);
}
};
for (provider, entry) in &pi_auth.providers {
if saorsa_auth.providers.contains_key(provider) {
continue;
}
saorsa_auth
.providers
.insert(provider.clone(), entry.clone());
imported.push(provider.clone());
}
if !imported.is_empty()
&& let Err(e) = auth::save(&saorsa_auth, &target)
{
warnings.push(format!("failed to save {}: {e}", target.display()));
}
(imported, warnings)
}
fn import_pi_models(pi_dir: &Path, saorsa_dir: &Path) -> (Vec<String>, Vec<String>) {
let mut imported = Vec::new();
let mut warnings = Vec::new();
let source = pi_dir.join("models.json");
if !source.exists() {
return (imported, warnings);
}
let pi_models = match models::load(&source) {
Ok(cfg) => cfg,
Err(e) => {
warnings.push(format!("failed to load {}: {e}", source.display()));
return (imported, warnings);
}
};
let target = saorsa_dir.join("models.json");
let saorsa_models = match models::load(&target) {
Ok(cfg) => cfg,
Err(e) => {
warnings.push(format!("failed to load {}: {e}", target.display()));
return (imported, warnings);
}
};
for (provider_name, provider) in &pi_models.providers {
for model in &provider.models {
let is_new = saorsa_models
.providers
.get(provider_name)
.is_none_or(|existing| !existing.models.iter().any(|m| m.id == model.id));
if is_new {
imported.push(model.id.clone());
}
}
}
if !imported.is_empty() {
let merged = models::merge(&saorsa_models, &pi_models);
if let Err(e) = models::save(&merged, &target) {
warnings.push(format!("failed to save {}: {e}", target.display()));
}
}
(imported, warnings)
}
fn import_pi_settings(pi_dir: &Path, saorsa_dir: &Path) -> (Vec<String>, Vec<String>) {
let mut skipped = Vec::new();
let mut warnings = Vec::new();
let source = pi_dir.join("settings.json");
if !source.exists() {
return (skipped, warnings);
}
let pi_settings = match settings::load(&source) {
Ok(cfg) => cfg,
Err(e) => {
warnings.push(format!("failed to load {}: {e}", source.display()));
return (skipped, warnings);
}
};
let target = saorsa_dir.join("settings.json");
let saorsa_settings = match settings::load(&target) {
Ok(cfg) => cfg,
Err(e) => {
warnings.push(format!("failed to load {}: {e}", target.display()));
return (skipped, warnings);
}
};
if saorsa_settings.default_provider.is_some() && pi_settings.default_provider.is_some() {
skipped.push("default_provider".to_string());
}
if saorsa_settings.default_model.is_some() && pi_settings.default_model.is_some() {
skipped.push("default_model".to_string());
}
let merged = settings::merge(&saorsa_settings, &pi_settings);
if let Err(e) = settings::save(&merged, &target) {
warnings.push(format!("failed to save {}: {e}", target.display()));
}
(skipped, warnings)
}
fn import_skills(sources: &[PathBuf], target: &Path) -> (Vec<String>, Vec<String>, Vec<String>) {
let mut imported = Vec::new();
let mut skipped = Vec::new();
let mut warnings = Vec::new();
for source_dir in sources {
if !source_dir.is_dir() {
continue;
}
let entries = match std::fs::read_dir(source_dir) {
Ok(e) => e,
Err(e) => {
warnings.push(format!(
"failed to read directory {}: {e}",
source_dir.display()
));
continue;
}
};
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(e) => {
warnings.push(format!("failed to read directory entry: {e}"));
continue;
}
};
let path = entry.path();
if path.is_file() && has_md_extension(&path) {
let file_name = match path.file_name() {
Some(n) => n.to_string_lossy().to_string(),
None => continue,
};
copy_skill_file(
&path,
target,
&file_name,
&mut imported,
&mut skipped,
&mut warnings,
);
continue;
}
if path.is_dir() {
let skill_md = path.join("SKILL.md");
if skill_md.is_file() {
let dir_name = match path.file_name() {
Some(n) => n.to_string_lossy().to_string(),
None => continue,
};
let target_name = format!("{dir_name}.md");
copy_skill_file(
&skill_md,
target,
&target_name,
&mut imported,
&mut skipped,
&mut warnings,
);
}
}
}
}
(imported, skipped, warnings)
}
fn import_agents(sources: &[PathBuf], target: &Path) -> (Vec<String>, Vec<String>, Vec<String>) {
let mut imported = Vec::new();
let mut skipped = Vec::new();
let mut warnings = Vec::new();
for source_dir in sources {
if !source_dir.is_dir() {
continue;
}
let walker = walkdir::WalkDir::new(source_dir)
.follow_links(false)
.into_iter();
for entry in walker {
let entry = match entry {
Ok(e) => e,
Err(e) => {
warnings.push(format!("failed to walk directory entry: {e}"));
continue;
}
};
let path = entry.path();
if !path.is_file() || !has_md_extension(path) {
continue;
}
let file_name = match path.file_name() {
Some(n) => n.to_string_lossy().to_string(),
None => continue,
};
copy_agent_file(
path,
target,
&file_name,
&mut imported,
&mut skipped,
&mut warnings,
);
}
}
(imported, skipped, warnings)
}
fn has_md_extension(path: &Path) -> bool {
path.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
}
fn copy_skill_file(
source: &Path,
target_dir: &Path,
target_name: &str,
imported: &mut Vec<String>,
skipped: &mut Vec<String>,
warnings: &mut Vec<String>,
) {
let dest = target_dir.join(target_name);
if dest.exists() {
skipped.push(format!("skill:{target_name}"));
return;
}
if let Err(e) = std::fs::create_dir_all(target_dir) {
warnings.push(format!(
"failed to create directory {}: {e}",
target_dir.display()
));
return;
}
match std::fs::copy(source, &dest) {
Ok(_) => {
let name = target_name.strip_suffix(".md").unwrap_or(target_name);
imported.push(name.to_string());
}
Err(e) => {
warnings.push(format!(
"failed to copy {} to {}: {e}",
source.display(),
dest.display()
));
}
}
}
fn copy_agent_file(
source: &Path,
target_dir: &Path,
target_name: &str,
imported: &mut Vec<String>,
skipped: &mut Vec<String>,
warnings: &mut Vec<String>,
) {
let dest = target_dir.join(target_name);
if dest.exists() {
skipped.push(format!("agent:{target_name}"));
return;
}
if let Err(e) = std::fs::create_dir_all(target_dir) {
warnings.push(format!(
"failed to create directory {}: {e}",
target_dir.display()
));
return;
}
match std::fs::copy(source, &dest) {
Ok(_) => {
let name = target_name.strip_suffix(".md").unwrap_or(target_name);
imported.push(name.to_string());
}
Err(e) => {
warnings.push(format!(
"failed to copy {} to {}: {e}",
source.display(),
dest.display()
));
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::config::auth::{AuthConfig, AuthEntry};
use crate::config::models::{CustomModel, CustomProvider, ModelsConfig};
use crate::config::settings::Settings;
use std::collections::HashMap;
struct TestDirs {
_tmp: tempfile::TempDir,
pi_dir: PathBuf,
claude_dir: PathBuf,
saorsa_dir: PathBuf,
}
impl TestDirs {
fn new() -> Self {
let tmp = tempfile::tempdir().unwrap();
let pi_dir = tmp.path().join("pi").join("agent");
let claude_dir = tmp.path().join("claude");
let saorsa_dir = tmp.path().join("saorsa");
std::fs::create_dir_all(&pi_dir).unwrap();
std::fs::create_dir_all(&claude_dir).unwrap();
std::fs::create_dir_all(&saorsa_dir).unwrap();
Self {
_tmp: tmp,
pi_dir,
claude_dir,
saorsa_dir,
}
}
}
#[test]
fn import_from_empty_dirs() {
let tmp = tempfile::tempdir().unwrap();
let pi_dir = tmp.path().join("nonexistent_pi");
let saorsa_dir = tmp.path().join("saorsa");
std::fs::create_dir_all(&saorsa_dir).unwrap();
let (keys, warnings) = import_pi_auth(&pi_dir, &saorsa_dir);
assert!(keys.is_empty());
assert!(warnings.is_empty());
let (models, warnings) = import_pi_models(&pi_dir, &saorsa_dir);
assert!(models.is_empty());
assert!(warnings.is_empty());
let (skipped, warnings) = import_pi_settings(&pi_dir, &saorsa_dir);
assert!(skipped.is_empty());
assert!(warnings.is_empty());
let (imported, skipped, warnings) = import_skills(&[], &saorsa_dir.join("skills"));
assert!(imported.is_empty());
assert!(skipped.is_empty());
assert!(warnings.is_empty());
let (imported, skipped, warnings) = import_agents(&[], &saorsa_dir.join("agents"));
assert!(imported.is_empty());
assert!(skipped.is_empty());
assert!(warnings.is_empty());
}
#[test]
fn import_pi_auth_merges() {
let dirs = TestDirs::new();
let mut pi_auth = AuthConfig::default();
pi_auth.providers.insert(
"anthropic".into(),
AuthEntry::ApiKey {
key: "sk-pi-anthropic".into(),
},
);
pi_auth.providers.insert(
"openai".into(),
AuthEntry::EnvVar {
name: "OPENAI_KEY".into(),
},
);
auth::save(&pi_auth, &dirs.pi_dir.join("auth.json")).unwrap();
let (keys, warnings) = import_pi_auth(&dirs.pi_dir, &dirs.saorsa_dir);
assert!(warnings.is_empty());
assert_eq!(keys.len(), 2);
assert!(keys.contains(&"anthropic".to_string()));
assert!(keys.contains(&"openai".to_string()));
let saved = auth::load(&dirs.saorsa_dir.join("auth.json")).unwrap();
assert_eq!(saved.providers.len(), 2);
assert!(saved.providers.contains_key("anthropic"));
assert!(saved.providers.contains_key("openai"));
}
#[test]
fn import_pi_auth_skips_existing() {
let dirs = TestDirs::new();
let mut saorsa_auth = AuthConfig::default();
saorsa_auth.providers.insert(
"anthropic".into(),
AuthEntry::ApiKey {
key: "sk-saorsa-existing".into(),
},
);
auth::save(&saorsa_auth, &dirs.saorsa_dir.join("auth.json")).unwrap();
let mut pi_auth = AuthConfig::default();
pi_auth.providers.insert(
"anthropic".into(),
AuthEntry::ApiKey {
key: "sk-pi-anthropic".into(),
},
);
pi_auth.providers.insert(
"openai".into(),
AuthEntry::ApiKey {
key: "sk-pi-openai".into(),
},
);
auth::save(&pi_auth, &dirs.pi_dir.join("auth.json")).unwrap();
let (keys, warnings) = import_pi_auth(&dirs.pi_dir, &dirs.saorsa_dir);
assert!(warnings.is_empty());
assert_eq!(keys, vec!["openai"]);
let saved = auth::load(&dirs.saorsa_dir.join("auth.json")).unwrap();
assert_eq!(saved.providers.len(), 2);
match saved.providers.get("anthropic").unwrap() {
AuthEntry::ApiKey { key } => assert_eq!(key, "sk-saorsa-existing"),
_ => panic!("expected ApiKey variant"),
}
}
#[test]
fn import_pi_models_merges() {
let dirs = TestDirs::new();
let mut pi_models = ModelsConfig::default();
pi_models.providers.insert(
"custom".into(),
CustomProvider {
base_url: "https://api.custom.com".into(),
api: Some("openai".into()),
api_key: None,
auth_header: None,
headers: HashMap::new(),
models: vec![CustomModel {
id: "custom-model-1".into(),
name: Some("Custom Model".into()),
context_window: Some(32_000),
max_tokens: Some(4096),
reasoning: false,
input: None,
cost: None,
}],
},
);
models::save(&pi_models, &dirs.pi_dir.join("models.json")).unwrap();
let (model_ids, warnings) = import_pi_models(&dirs.pi_dir, &dirs.saorsa_dir);
assert!(warnings.is_empty());
assert_eq!(model_ids, vec!["custom-model-1"]);
let saved = models::load(&dirs.saorsa_dir.join("models.json")).unwrap();
assert_eq!(saved.providers.len(), 1);
assert_eq!(saved.providers.get("custom").unwrap().models.len(), 1);
}
#[test]
fn import_skills_copies_md_files() {
let dirs = TestDirs::new();
let pi_skills = dirs.pi_dir.join("skills");
std::fs::create_dir_all(&pi_skills).unwrap();
std::fs::write(pi_skills.join("review.md"), "# Review skill").unwrap();
std::fs::write(pi_skills.join("commit.md"), "# Commit skill").unwrap();
let target = dirs.saorsa_dir.join("skills");
let (imported, skipped, warnings) = import_skills(&[pi_skills], &target);
assert!(warnings.is_empty());
assert!(skipped.is_empty());
assert_eq!(imported.len(), 2);
assert!(imported.contains(&"review".to_string()));
assert!(imported.contains(&"commit".to_string()));
assert!(target.join("review.md").exists());
assert!(target.join("commit.md").exists());
}
#[test]
fn import_skills_skips_existing() {
let dirs = TestDirs::new();
let target = dirs.saorsa_dir.join("skills");
std::fs::create_dir_all(&target).unwrap();
std::fs::write(target.join("review.md"), "# Existing review").unwrap();
let pi_skills = dirs.pi_dir.join("skills");
std::fs::create_dir_all(&pi_skills).unwrap();
std::fs::write(pi_skills.join("review.md"), "# Pi review").unwrap();
std::fs::write(pi_skills.join("deploy.md"), "# Pi deploy").unwrap();
let (imported, skipped, warnings) = import_skills(&[pi_skills], &target);
assert!(warnings.is_empty());
assert_eq!(imported, vec!["deploy"]);
assert_eq!(skipped, vec!["skill:review.md"]);
let content = std::fs::read_to_string(target.join("review.md")).unwrap();
assert_eq!(content, "# Existing review");
}
#[test]
fn import_claude_skills_from_subdirs() {
let dirs = TestDirs::new();
let claude_skills = dirs.claude_dir.join("skills");
let skill_dir = claude_skills.join("gsd-commit");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(skill_dir.join("SKILL.md"), "# GSD Commit").unwrap();
let another = claude_skills.join("gsd-review");
std::fs::create_dir_all(&another).unwrap();
std::fs::write(another.join("SKILL.md"), "# GSD Review").unwrap();
let target = dirs.saorsa_dir.join("skills");
let (imported, skipped, warnings) = import_skills(&[claude_skills], &target);
assert!(warnings.is_empty());
assert!(skipped.is_empty());
assert_eq!(imported.len(), 2);
assert!(imported.contains(&"gsd-commit".to_string()));
assert!(imported.contains(&"gsd-review".to_string()));
assert!(target.join("gsd-commit.md").exists());
assert!(target.join("gsd-review.md").exists());
}
#[test]
fn import_agents_copies_md_files() {
let dirs = TestDirs::new();
let agents_dir = dirs.claude_dir.join("agents");
let category = agents_dir.join("review");
std::fs::create_dir_all(&category).unwrap();
std::fs::write(category.join("security-scanner.md"), "# Security").unwrap();
std::fs::write(category.join("code-reviewer.md"), "# Code Review").unwrap();
let target = dirs.saorsa_dir.join("agents");
let (imported, skipped, warnings) = import_agents(&[agents_dir], &target);
assert!(warnings.is_empty());
assert!(skipped.is_empty());
assert_eq!(imported.len(), 2);
assert!(imported.contains(&"security-scanner".to_string()));
assert!(imported.contains(&"code-reviewer".to_string()));
assert!(target.join("security-scanner.md").exists());
assert!(target.join("code-reviewer.md").exists());
}
#[test]
fn import_agents_skips_existing() {
let dirs = TestDirs::new();
let target = dirs.saorsa_dir.join("agents");
std::fs::create_dir_all(&target).unwrap();
std::fs::write(target.join("code-reviewer.md"), "# Existing").unwrap();
let agents_dir = dirs.claude_dir.join("agents");
std::fs::create_dir_all(&agents_dir).unwrap();
std::fs::write(agents_dir.join("code-reviewer.md"), "# New").unwrap();
std::fs::write(agents_dir.join("dev-agent.md"), "# Dev Agent").unwrap();
let (imported, skipped, warnings) = import_agents(&[agents_dir], &target);
assert!(warnings.is_empty());
assert_eq!(imported, vec!["dev-agent"]);
assert_eq!(skipped, vec!["agent:code-reviewer.md"]);
let content = std::fs::read_to_string(target.join("code-reviewer.md")).unwrap();
assert_eq!(content, "# Existing");
}
#[test]
fn import_all_comprehensive() {
let dirs = TestDirs::new();
let mut pi_auth = AuthConfig::default();
pi_auth.providers.insert(
"anthropic".into(),
AuthEntry::ApiKey {
key: "sk-ant".into(),
},
);
auth::save(&pi_auth, &dirs.pi_dir.join("auth.json")).unwrap();
let mut pi_models = ModelsConfig::default();
pi_models.providers.insert(
"local".into(),
CustomProvider {
base_url: "http://localhost:8080".into(),
api: Some("openai".into()),
api_key: None,
auth_header: None,
headers: HashMap::new(),
models: vec![CustomModel {
id: "llama-3".into(),
name: Some("Llama 3".into()),
context_window: Some(8192),
max_tokens: Some(2048),
reasoning: false,
input: None,
cost: None,
}],
},
);
models::save(&pi_models, &dirs.pi_dir.join("models.json")).unwrap();
let pi_settings = Settings {
default_provider: Some("anthropic".into()),
default_model: Some("claude-sonnet".into()),
..Settings::default()
};
settings::save(&pi_settings, &dirs.pi_dir.join("settings.json")).unwrap();
let pi_skills = dirs.pi_dir.join("skills");
std::fs::create_dir_all(&pi_skills).unwrap();
std::fs::write(pi_skills.join("test-skill.md"), "# Test").unwrap();
let claude_skills = dirs.claude_dir.join("skills");
let skill_subdir = claude_skills.join("gsd");
std::fs::create_dir_all(&skill_subdir).unwrap();
std::fs::write(skill_subdir.join("SKILL.md"), "# GSD").unwrap();
let claude_agents = dirs.claude_dir.join("agents");
let agent_subdir = claude_agents.join("core");
std::fs::create_dir_all(&agent_subdir).unwrap();
std::fs::write(agent_subdir.join("dev-agent.md"), "# Dev").unwrap();
let mut report = ImportReport::default();
let (keys, warnings) = import_pi_auth(&dirs.pi_dir, &dirs.saorsa_dir);
report.imported_keys.extend(keys);
report.warnings.extend(warnings);
let (model_ids, warnings) = import_pi_models(&dirs.pi_dir, &dirs.saorsa_dir);
report.imported_models.extend(model_ids);
report.warnings.extend(warnings);
let (skipped, warnings) = import_pi_settings(&dirs.pi_dir, &dirs.saorsa_dir);
report.skipped.extend(skipped);
report.warnings.extend(warnings);
let skill_sources = vec![pi_skills, claude_skills];
let target_skills = dirs.saorsa_dir.join("skills");
let (imported, skipped, warnings) = import_skills(&skill_sources, &target_skills);
report.imported_skills.extend(imported);
report.skipped.extend(skipped);
report.warnings.extend(warnings);
let agent_sources = vec![claude_agents];
let target_agents = dirs.saorsa_dir.join("agents");
let (imported, skipped, warnings) = import_agents(&agent_sources, &target_agents);
report.imported_agents.extend(imported);
report.skipped.extend(skipped);
report.warnings.extend(warnings);
assert!(
report.warnings.is_empty(),
"warnings: {:?}",
report.warnings
);
assert_eq!(report.imported_keys, vec!["anthropic"]);
assert_eq!(report.imported_models, vec!["llama-3"]);
assert_eq!(report.imported_skills.len(), 2);
assert!(report.imported_skills.contains(&"test-skill".to_string()));
assert!(report.imported_skills.contains(&"gsd".to_string()));
assert_eq!(report.imported_agents, vec!["dev-agent"]);
assert!(dirs.saorsa_dir.join("auth.json").exists());
assert!(dirs.saorsa_dir.join("models.json").exists());
assert!(dirs.saorsa_dir.join("settings.json").exists());
assert!(
dirs.saorsa_dir
.join("skills")
.join("test-skill.md")
.exists()
);
assert!(dirs.saorsa_dir.join("skills").join("gsd.md").exists());
assert!(dirs.saorsa_dir.join("agents").join("dev-agent.md").exists());
}
#[test]
fn import_pi_settings_merges_correctly() {
let dirs = TestDirs::new();
let saorsa_settings = Settings {
default_provider: Some("anthropic".into()),
default_model: None,
..Settings::default()
};
settings::save(&saorsa_settings, &dirs.saorsa_dir.join("settings.json")).unwrap();
let pi_settings = Settings {
default_provider: Some("openai".into()),
default_model: Some("gpt-4".into()),
..Settings::default()
};
settings::save(&pi_settings, &dirs.pi_dir.join("settings.json")).unwrap();
let (skipped, warnings) = import_pi_settings(&dirs.pi_dir, &dirs.saorsa_dir);
assert!(warnings.is_empty());
assert!(skipped.contains(&"default_provider".to_string()));
let saved = settings::load(&dirs.saorsa_dir.join("settings.json")).unwrap();
assert!(saved.default_model.is_some());
}
#[test]
fn has_md_extension_works() {
assert!(has_md_extension(Path::new("foo.md")));
assert!(has_md_extension(Path::new("foo.MD")));
assert!(has_md_extension(Path::new("/path/to/file.md")));
assert!(!has_md_extension(Path::new("foo.txt")));
assert!(!has_md_extension(Path::new("foo.json")));
assert!(!has_md_extension(Path::new("foo")));
}
}