use std::path::{Path, PathBuf};
use serde_json::{Map, Value};
use yapi_types::config::ConfigFile;
const CHANGELOG_VERSION: &str = "lastChangelogVersion";
pub const AGENT_ENTRIES: &[&str] = &[
"settings.json",
"auth.json",
"models.json",
"keybindings.json",
"mcp.json",
"mcp-auth.json",
"trust.json",
"SYSTEM.md",
"APPEND_SYSTEM.md",
"AGENTS.override.md",
"AGENTS.md",
"AGENTS.MD",
"CLAUDE.md",
"CLAUDE.MD",
"sessions",
"prompts",
"skills",
"themes",
"extensions",
"npm",
"git",
];
pub const PROJECT_ENTRIES: &[&str] = &[
"settings.json",
"mcp.json",
"SYSTEM.md",
"APPEND_SYSTEM.md",
"prompts",
"skills",
"themes",
"extensions",
"npm",
"git",
];
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Imported {
pub entry: String,
pub copied: usize,
pub kept: usize,
pub signed_out: Vec<String>,
}
pub fn pi_agent_dir() -> PathBuf {
match std::env::var("PI_CODING_AGENT_DIR") {
Ok(dir) if !dir.is_empty() => PathBuf::from(crate::tools::path::expand(&dir)),
_ => crate::tools::path::home_dir().join(".pi").join("agent"),
}
}
fn at(path: &Path, error: std::io::Error) -> std::io::Error {
std::io::Error::new(error.kind(), format!("{}: {error}", path.display()))
}
fn is_package(dir: &Path) -> bool {
let name = |path: &Path| {
let name = path.file_name().unwrap_or_default();
name.to_string_lossy().into_owned()
};
let Some(parent) = dir.parent() else {
return false;
};
dir.join(".git").exists()
|| (name(parent) == "node_modules" && !name(dir).starts_with('@'))
|| (name(parent).starts_with('@')
&& parent.parent().is_some_and(|up| name(up) == "node_modules"))
}
fn count_files(dir: &Path) -> usize {
let entries = std::fs::read_dir(dir).into_iter().flatten().flatten();
entries
.map(|entry| match entry.file_type() {
Ok(kind) if kind.is_dir() => count_files(&entry.path()),
_ => 1,
})
.sum()
}
fn copy_tree(
from: &Path,
to: &Path,
packages: bool,
imported: &mut Imported,
ancestors: &mut Vec<PathBuf>,
) -> std::io::Result<()> {
let metadata = match std::fs::metadata(from) {
Ok(metadata) => metadata,
Err(_) if from.symlink_metadata().is_ok_and(|link| link.is_symlink()) => return Ok(()),
Err(error) => return Err(at(from, error)),
};
if metadata.is_dir() {
let real = std::fs::canonicalize(from).map_err(|error| at(from, error))?;
if ancestors.contains(&real) {
return Ok(());
}
if packages && to.exists() && is_package(from) {
imported.kept += count_files(from);
return Ok(());
}
std::fs::create_dir_all(to).map_err(|error| at(to, error))?;
let mut entries: Vec<_> = std::fs::read_dir(from)
.map_err(|error| at(from, error))?
.flatten()
.collect();
entries.sort_by_key(std::fs::DirEntry::file_name);
ancestors.push(real);
for entry in entries {
copy_tree(
&entry.path(),
&to.join(entry.file_name()),
packages,
imported,
ancestors,
)?;
}
ancestors.pop();
return Ok(());
}
if to.exists() {
imported.kept += 1;
return Ok(());
}
if let Some(parent) = to.parent() {
std::fs::create_dir_all(parent).map_err(|error| at(parent, error))?;
}
std::fs::copy(from, to).map_err(|error| at(from, error))?;
imported.copied += 1;
Ok(())
}
fn settings(path: &Path) -> Option<Map<String, Value>> {
serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
}
fn drop_sign_ins(path: &Path, file: ConfigFile) -> std::io::Result<Vec<String>> {
let Some(mut document) = settings(path) else {
return Ok(Vec::new());
};
let signed_in = |state: &Value| match file {
ConfigFile::Auth => state["type"] == "oauth",
_ => state.get("tokens").is_some(),
};
let names: Vec<String> = document
.iter()
.filter(|(_, state)| signed_in(state))
.map(|(key, _)| {
match key
.strip_prefix("mcp__")
.and_then(|rest| rest.split_once('|'))
{
Some((server, _)) => server.to_owned(),
None => key.clone(),
}
})
.collect();
if !names.is_empty() {
document.retain(|_, state| !signed_in(state));
let text = file.render(&document).map_err(std::io::Error::other)?;
yapi_ai::auth::lock::write_atomic(path, text.as_bytes(), 0o600)
.map_err(|error| at(path, error))?;
}
Ok(names)
}
pub fn import(from: &Path, to: &Path, entries: &[&str]) -> std::io::Result<Vec<Imported>> {
let mut out = Vec::new();
for entry in entries {
let source = from.join(entry);
if source.symlink_metadata().is_err() {
continue;
}
let mut imported = Imported {
entry: (*entry).to_owned(),
..Imported::default()
};
let target = to.join(entry);
let is_settings = *entry == ConfigFile::Settings.file_name();
if is_settings
&& settings(&target).is_some_and(|document| {
document.len() == 1 && document.contains_key(CHANGELOG_VERSION)
})
{
std::fs::remove_file(&target).map_err(|error| at(&target, error))?;
}
let packages = matches!(*entry, "npm" | "git");
copy_tree(&source, &target, packages, &mut imported, &mut Vec::new())?;
if is_settings
&& imported.copied == 1
&& let Some(mut document) = settings(&target)
&& document.shift_remove(CHANGELOG_VERSION).is_some()
{
let text = ConfigFile::Settings
.render(&document)
.map_err(std::io::Error::other)?;
yapi_ai::auth::lock::write_atomic(&target, text.as_bytes(), 0o666)
.map_err(|error| at(&target, error))?;
}
let credentials = [ConfigFile::Auth, ConfigFile::McpAuth]
.into_iter()
.find(|file| *entry == file.file_name());
if let Some(file) = credentials
&& imported.copied == 1
{
imported.signed_out = drop_sign_ins(&target, file)?;
}
out.push(imported);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn write(root: &Path, name: &str, text: &str) {
let path = root.join(name);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
}
#[test]
fn copies_state_keeps_existing() {
let dir = std::env::temp_dir().join(format!("yapi-import-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let (pi, yapi) = (dir.join("pi"), dir.join("yapi"));
write(&pi, "settings.json", "{\"theme\":\"light\"}\n");
write(&pi, "auth.json", "{}\n");
write(&pi, "mcp-auth.json", "{}\n");
write(&pi, "sessions/--a--/one.jsonl", "{}\n");
write(&pi, "sessions/--a--/two.jsonl", "{}\n");
write(&pi, "bin/fd", "binary");
write(&pi, "pi-debug.log", "log");
write(&yapi, "auth.json", "{\"mine\":true}\n");
write(&pi, "npm/node_modules/foo/package.json", "2");
write(
&pi,
"npm/node_modules/foo/node_modules/bar/package.json",
"2",
);
write(&pi, "npm/node_modules/@s/baz/package.json", "1");
write(&pi, "npm/node_modules/@s/qux/package.json", "1");
write(&pi, "git/github.com/u/r/.git/HEAD", "pi");
write(&pi, "git/github.com/u/r/index.ts", "pi");
write(&yapi, "npm/node_modules/foo/package.json", "1");
write(&yapi, "npm/node_modules/@s/qux/package.json", "1");
write(&yapi, "git/github.com/u/r/.git/HEAD", "yapi");
let imported = import(&pi, &yapi, AGENT_ENTRIES).unwrap();
let summary: Vec<(&str, usize, usize)> = imported
.iter()
.map(|entry| (entry.entry.as_str(), entry.copied, entry.kept))
.collect();
assert_eq!(
summary,
[
("settings.json", 1, 0),
("auth.json", 0, 1),
("mcp-auth.json", 1, 0),
("sessions", 2, 0),
("npm", 1, 3),
("git", 0, 2)
]
);
assert!(!yapi.join("npm/node_modules/foo/node_modules").exists());
assert!(yapi.join("npm/node_modules/@s/baz/package.json").is_file());
assert!(!yapi.join("git/github.com/u/r/index.ts").exists());
assert_eq!(
std::fs::read_to_string(yapi.join("auth.json")).unwrap(),
"{\"mine\":true}\n"
);
assert!(yapi.join("sessions/--a--/two.jsonl").is_file());
assert!(!yapi.join("bin").exists());
assert!(!yapi.join("pi-debug.log").exists());
}
#[test]
fn skips_sign_ins() {
let dir = std::env::temp_dir().join(format!("yapi-import-auth-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let (pi, yapi) = (dir.join("pi"), dir.join("yapi"));
let oauth =
r#""anthropic": {"type": "oauth", "refresh": "r", "access": "a", "expires": 1}"#;
let api_key = "\"openai\": {\n \"type\": \"api_key\",\n \"key\": \"sk-1\"\n }";
write(
&pi,
"auth.json",
&format!("{{\n {oauth},\n {api_key}\n}}"),
);
let url = "https://mcp.example.com/mcp";
let states = serde_json::json!({
format!("mcp__work|{url}"): {"serverUrl": url, "tokens": {"access_token": "t"}},
url: {"tokens": {}},
format!("mcp__new|{url}"): {"codeVerifier": "v"},
});
write(&pi, "mcp-auth.json", &states.to_string());
let imported = import(&pi, &yapi, AGENT_ENTRIES).unwrap();
let signed_out: Vec<(&str, Vec<&str>)> = imported
.iter()
.map(|entry| {
let names = entry.signed_out.iter().map(String::as_str).collect();
(entry.entry.as_str(), names)
})
.collect();
assert_eq!(
signed_out,
[
("auth.json", vec!["anthropic"]),
("mcp-auth.json", vec!["work", url])
]
);
assert_eq!(
std::fs::read_to_string(yapi.join("auth.json")).unwrap(),
format!("{{\n {api_key}\n}}")
);
let states = std::fs::read_to_string(yapi.join("mcp-auth.json")).unwrap();
assert_eq!(
serde_json::from_str::<Value>(&states).unwrap(),
serde_json::json!({format!("mcp__new|{url}"): {"codeVerifier": "v"}})
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn settings_replace_first_start() {
let dir = std::env::temp_dir().join(format!("yapi-import-settings-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let (pi, yapi) = (dir.join("pi"), dir.join("yapi"));
let pi_settings = "{\n \"theme\": \"light\",\n \"lastChangelogVersion\": \"1.0.0\",\n \"defaultProvider\": \"anthropic\"\n}";
write(&pi, "settings.json", pi_settings);
write(
&yapi,
"settings.json",
"{\n \"lastChangelogVersion\": \"0.1.0\"\n}",
);
let imported = import(&pi, &yapi, &["settings.json"]).unwrap();
assert_eq!((imported[0].copied, imported[0].kept), (1, 0));
assert_eq!(
std::fs::read_to_string(yapi.join("settings.json")).unwrap(),
"{\n \"theme\": \"light\",\n \"defaultProvider\": \"anthropic\"\n}"
);
let kept = dir.join("kept");
write(
&kept,
"settings.json",
"{\"lastChangelogVersion\":\"0.1.0\",\"theme\":\"dark\"}",
);
let imported = import(&pi, &kept, &["settings.json"]).unwrap();
assert_eq!((imported[0].copied, imported[0].kept), (0, 1));
assert_eq!(
std::fs::read_to_string(kept.join("settings.json")).unwrap(),
"{\"lastChangelogVersion\":\"0.1.0\",\"theme\":\"dark\"}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn context_files_and_symlinks() {
use std::os::unix::fs::symlink;
let dir = std::env::temp_dir().join(format!("yapi-import-links-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let (pi, yapi) = (dir.join("pi"), dir.join("yapi"));
write(&pi, "AGENTS.md", "global rules\n");
write(&pi, "skills/real/SKILL.md", "skill\n");
symlink(pi.join("nowhere"), pi.join("skills/dangling")).unwrap();
symlink(pi.join("skills/loop-b"), pi.join("skills/loop-a")).unwrap();
symlink(pi.join("skills/loop-a"), pi.join("skills/loop-b")).unwrap();
symlink(pi.join("skills"), pi.join("skills/real/up")).unwrap();
symlink(pi.join("skills/real"), pi.join("skills/alias")).unwrap();
let imported = import(&pi, &yapi, AGENT_ENTRIES).unwrap();
let summary: Vec<(&str, usize)> = imported
.iter()
.filter(|entry| entry.copied > 0)
.map(|entry| (entry.entry.as_str(), entry.copied))
.collect();
assert_eq!(summary, [("AGENTS.md", 1), ("skills", 2)]);
assert!(yapi.join("skills/alias/SKILL.md").is_file());
assert!(!yapi.join("skills/dangling").exists());
let _ = std::fs::remove_dir_all(&dir);
}
}