1use std::path::{Path, PathBuf};
6
7use serde_json::{Map, Value};
8use yapi_types::config::ConfigFile;
9
10const CHANGELOG_VERSION: &str = "lastChangelogVersion";
12
13pub const AGENT_ENTRIES: &[&str] = &[
15 "settings.json",
16 "auth.json",
17 "models.json",
18 "keybindings.json",
19 "mcp.json",
20 "mcp-auth.json",
21 "trust.json",
22 "SYSTEM.md",
23 "APPEND_SYSTEM.md",
24 "AGENTS.override.md",
25 "AGENTS.md",
26 "AGENTS.MD",
27 "CLAUDE.md",
28 "CLAUDE.MD",
29 "sessions",
30 "prompts",
31 "skills",
32 "themes",
33 "extensions",
34 "npm",
35 "git",
36];
37
38pub const PROJECT_ENTRIES: &[&str] = &[
41 "settings.json",
42 "mcp.json",
43 "SYSTEM.md",
44 "APPEND_SYSTEM.md",
45 "prompts",
46 "skills",
47 "themes",
48 "extensions",
49 "npm",
50 "git",
51];
52
53#[derive(Clone, Debug, Default, PartialEq, Eq)]
55pub struct Imported {
56 pub entry: String,
58 pub copied: usize,
60 pub kept: usize,
63 pub signed_out: Vec<String>,
67 pub beside_toml: bool,
70}
71
72pub fn pi_agent_dir() -> PathBuf {
74 match std::env::var("PI_CODING_AGENT_DIR") {
75 Ok(dir) if !dir.is_empty() => PathBuf::from(crate::tools::path::expand(&dir)),
76 _ => crate::tools::path::home_dir().join(".pi").join("agent"),
77 }
78}
79
80fn at(path: &Path, error: std::io::Error) -> std::io::Error {
82 std::io::Error::new(error.kind(), format!("{}: {error}", path.display()))
83}
84
85fn is_package(dir: &Path) -> bool {
88 let name = |path: &Path| {
89 let name = path.file_name().unwrap_or_default();
90 name.to_string_lossy().into_owned()
91 };
92 let Some(parent) = dir.parent() else {
93 return false;
94 };
95 dir.join(".git").exists()
96 || (name(parent) == "node_modules" && !name(dir).starts_with('@'))
97 || (name(parent).starts_with('@')
98 && parent.parent().is_some_and(|up| name(up) == "node_modules"))
99}
100
101fn count_files(dir: &Path) -> usize {
103 let entries = std::fs::read_dir(dir).into_iter().flatten().flatten();
104 entries
105 .map(|entry| match entry.file_type() {
106 Ok(kind) if kind.is_dir() => count_files(&entry.path()),
107 _ => 1,
108 })
109 .sum()
110}
111
112fn copy_tree(
117 from: &Path,
118 to: &Path,
119 packages: bool,
120 imported: &mut Imported,
121 ancestors: &mut Vec<PathBuf>,
122) -> std::io::Result<()> {
123 let metadata = match std::fs::metadata(from) {
124 Ok(metadata) => metadata,
125 Err(_) if from.symlink_metadata().is_ok_and(|link| link.is_symlink()) => return Ok(()),
126 Err(error) => return Err(at(from, error)),
127 };
128 if metadata.is_dir() {
129 let real = std::fs::canonicalize(from).map_err(|error| at(from, error))?;
130 if ancestors.contains(&real) {
131 return Ok(());
132 }
133 if packages && to.exists() && is_package(from) {
134 imported.kept += count_files(from);
135 return Ok(());
136 }
137 std::fs::create_dir_all(to).map_err(|error| at(to, error))?;
138 let mut entries: Vec<_> = std::fs::read_dir(from)
139 .map_err(|error| at(from, error))?
140 .flatten()
141 .collect();
142 entries.sort_by_key(std::fs::DirEntry::file_name);
143 ancestors.push(real);
144 for entry in entries {
145 copy_tree(
146 &entry.path(),
147 &to.join(entry.file_name()),
148 packages,
149 imported,
150 ancestors,
151 )?;
152 }
153 ancestors.pop();
154 return Ok(());
155 }
156 if to.exists() {
157 imported.kept += 1;
158 return Ok(());
159 }
160 if let Some(parent) = to.parent() {
161 std::fs::create_dir_all(parent).map_err(|error| at(parent, error))?;
162 }
163 std::fs::copy(from, to).map_err(|error| at(from, error))?;
165 imported.copied += 1;
166 Ok(())
167}
168
169fn settings(path: &Path) -> Option<Map<String, Value>> {
171 serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
172}
173
174fn drop_sign_ins(path: &Path, file: ConfigFile) -> std::io::Result<Vec<String>> {
178 let Some(mut document) = settings(path) else {
179 return Ok(Vec::new());
180 };
181 let signed_in = |state: &Value| match file {
182 ConfigFile::Auth => state["type"] == "oauth",
183 _ => state.get("tokens").is_some(),
184 };
185 let names: Vec<String> = document
186 .iter()
187 .filter(|(_, state)| signed_in(state))
188 .map(|(key, _)| {
190 match key
191 .strip_prefix("mcp__")
192 .and_then(|rest| rest.split_once('|'))
193 {
194 Some((server, _)) => server.to_owned(),
195 None => key.clone(),
196 }
197 })
198 .collect();
199 if !names.is_empty() {
200 document.retain(|_, state| !signed_in(state));
201 let text = file.render(&document).map_err(std::io::Error::other)?;
202 yapi_ai::auth::lock::write_atomic(path, text.as_bytes(), 0o600)
203 .map_err(|error| at(path, error))?;
204 }
205 Ok(names)
206}
207
208pub fn import(from: &Path, to: &Path, entries: &[&str]) -> std::io::Result<Vec<Imported>> {
217 let mut out = Vec::new();
218 for entry in entries {
219 let source = from.join(entry);
220 if source.symlink_metadata().is_err() {
221 continue;
222 }
223 let mut imported = Imported {
224 entry: (*entry).to_owned(),
225 ..Imported::default()
226 };
227 let target = to.join(entry);
228 let is_settings = *entry == ConfigFile::Settings.file_name();
229 if is_settings && to.join(crate::settings::TOML_FILE).exists() {
230 imported.beside_toml = true;
231 out.push(imported);
232 continue;
233 }
234 if is_settings
235 && settings(&target).is_some_and(|document| {
236 document.len() == 1 && document.contains_key(CHANGELOG_VERSION)
237 })
238 {
239 std::fs::remove_file(&target).map_err(|error| at(&target, error))?;
240 }
241 let packages = matches!(*entry, "npm" | "git");
242 copy_tree(&source, &target, packages, &mut imported, &mut Vec::new())?;
243 if is_settings
244 && imported.copied == 1
245 && let Some(mut document) = settings(&target)
246 && document.shift_remove(CHANGELOG_VERSION).is_some()
247 {
248 let text = ConfigFile::Settings
249 .render(&document)
250 .map_err(std::io::Error::other)?;
251 yapi_ai::auth::lock::write_atomic(&target, text.as_bytes(), 0o666)
252 .map_err(|error| at(&target, error))?;
253 }
254 let credentials = [ConfigFile::Auth, ConfigFile::McpAuth]
255 .into_iter()
256 .find(|file| *entry == file.file_name());
257 if let Some(file) = credentials
258 && imported.copied == 1
259 {
260 imported.signed_out = drop_sign_ins(&target, file)?;
261 }
262 out.push(imported);
263 }
264 Ok(out)
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270
271 fn write(root: &Path, name: &str, text: &str) {
272 let path = root.join(name);
273 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
274 std::fs::write(path, text).unwrap();
275 }
276
277 #[test]
278 fn copies_state_keeps_existing() {
279 let dir = std::env::temp_dir().join(format!("yapi-import-{}", std::process::id()));
280 let _ = std::fs::remove_dir_all(&dir);
281 let (pi, yapi) = (dir.join("pi"), dir.join("yapi"));
282 write(&pi, "settings.json", "{\"theme\":\"light\"}\n");
283 write(&pi, "auth.json", "{}\n");
284 write(&pi, "mcp-auth.json", "{}\n");
285 write(&pi, "sessions/--a--/one.jsonl", "{}\n");
286 write(&pi, "sessions/--a--/two.jsonl", "{}\n");
287 write(&pi, "bin/fd", "binary");
288 write(&pi, "pi-debug.log", "log");
289 write(&yapi, "auth.json", "{\"mine\":true}\n");
290 write(&pi, "npm/node_modules/foo/package.json", "2");
292 write(
293 &pi,
294 "npm/node_modules/foo/node_modules/bar/package.json",
295 "2",
296 );
297 write(&pi, "npm/node_modules/@s/baz/package.json", "1");
298 write(&pi, "npm/node_modules/@s/qux/package.json", "1");
299 write(&pi, "git/github.com/u/r/.git/HEAD", "pi");
300 write(&pi, "git/github.com/u/r/index.ts", "pi");
301 write(&yapi, "npm/node_modules/foo/package.json", "1");
302 write(&yapi, "npm/node_modules/@s/qux/package.json", "1");
303 write(&yapi, "git/github.com/u/r/.git/HEAD", "yapi");
304
305 let imported = import(&pi, &yapi, AGENT_ENTRIES).unwrap();
306
307 let summary: Vec<(&str, usize, usize)> = imported
308 .iter()
309 .map(|entry| (entry.entry.as_str(), entry.copied, entry.kept))
310 .collect();
311 assert_eq!(
312 summary,
313 [
314 ("settings.json", 1, 0),
315 ("auth.json", 0, 1),
316 ("mcp-auth.json", 1, 0),
317 ("sessions", 2, 0),
318 ("npm", 1, 3),
319 ("git", 0, 2)
320 ]
321 );
322 assert!(!yapi.join("npm/node_modules/foo/node_modules").exists());
323 assert!(yapi.join("npm/node_modules/@s/baz/package.json").is_file());
324 assert!(!yapi.join("git/github.com/u/r/index.ts").exists());
325 assert_eq!(
326 std::fs::read_to_string(yapi.join("auth.json")).unwrap(),
327 "{\"mine\":true}\n"
328 );
329 assert!(yapi.join("sessions/--a--/two.jsonl").is_file());
330 assert!(!yapi.join("bin").exists());
331 assert!(!yapi.join("pi-debug.log").exists());
332 }
333
334 #[test]
335 fn skips_sign_ins() {
336 let dir = std::env::temp_dir().join(format!("yapi-import-auth-{}", std::process::id()));
337 let _ = std::fs::remove_dir_all(&dir);
338 let (pi, yapi) = (dir.join("pi"), dir.join("yapi"));
339 let oauth =
340 r#""anthropic": {"type": "oauth", "refresh": "r", "access": "a", "expires": 1}"#;
341 let api_key = "\"openai\": {\n \"type\": \"api_key\",\n \"key\": \"sk-1\"\n }";
342 write(
343 &pi,
344 "auth.json",
345 &format!("{{\n {oauth},\n {api_key}\n}}"),
346 );
347 let url = "https://mcp.example.com/mcp";
348 let states = serde_json::json!({
349 format!("mcp__work|{url}"): {"serverUrl": url, "tokens": {"access_token": "t"}},
350 url: {"tokens": {}},
351 format!("mcp__new|{url}"): {"codeVerifier": "v"},
352 });
353 write(&pi, "mcp-auth.json", &states.to_string());
354
355 let imported = import(&pi, &yapi, AGENT_ENTRIES).unwrap();
356
357 let signed_out: Vec<(&str, Vec<&str>)> = imported
358 .iter()
359 .map(|entry| {
360 let names = entry.signed_out.iter().map(String::as_str).collect();
361 (entry.entry.as_str(), names)
362 })
363 .collect();
364 assert_eq!(
365 signed_out,
366 [
367 ("auth.json", vec!["anthropic"]),
368 ("mcp-auth.json", vec!["work", url])
369 ]
370 );
371 assert_eq!(
372 std::fs::read_to_string(yapi.join("auth.json")).unwrap(),
373 format!("{{\n {api_key}\n}}")
374 );
375 let states = std::fs::read_to_string(yapi.join("mcp-auth.json")).unwrap();
376 assert_eq!(
377 serde_json::from_str::<Value>(&states).unwrap(),
378 serde_json::json!({format!("mcp__new|{url}"): {"codeVerifier": "v"}})
379 );
380 let _ = std::fs::remove_dir_all(&dir);
381 }
382
383 #[test]
384 fn settings_replace_first_start() {
385 let dir = std::env::temp_dir().join(format!("yapi-import-settings-{}", std::process::id()));
386 let _ = std::fs::remove_dir_all(&dir);
387 let (pi, yapi) = (dir.join("pi"), dir.join("yapi"));
388 let pi_settings = "{\n \"theme\": \"light\",\n \"lastChangelogVersion\": \"1.0.0\",\n \"defaultProvider\": \"anthropic\"\n}";
389 write(&pi, "settings.json", pi_settings);
390 write(
392 &yapi,
393 "settings.json",
394 "{\n \"lastChangelogVersion\": \"0.1.0\"\n}",
395 );
396
397 let imported = import(&pi, &yapi, &["settings.json"]).unwrap();
398 assert_eq!((imported[0].copied, imported[0].kept), (1, 0));
399 assert_eq!(
400 std::fs::read_to_string(yapi.join("settings.json")).unwrap(),
401 "{\n \"theme\": \"light\",\n \"defaultProvider\": \"anthropic\"\n}"
402 );
403
404 let kept = dir.join("kept");
406 write(
407 &kept,
408 "settings.json",
409 "{\"lastChangelogVersion\":\"0.1.0\",\"theme\":\"dark\"}",
410 );
411 let imported = import(&pi, &kept, &["settings.json"]).unwrap();
412 assert_eq!((imported[0].copied, imported[0].kept), (0, 1));
413 assert_eq!(
414 std::fs::read_to_string(kept.join("settings.json")).unwrap(),
415 "{\"lastChangelogVersion\":\"0.1.0\",\"theme\":\"dark\"}"
416 );
417
418 let toml = dir.join("toml");
420 write(&toml, "settings.toml", "theme = \"dark\"\n");
421 let imported = import(&pi, &toml, &["settings.json"]).unwrap();
422 assert!(imported[0].beside_toml && imported[0].copied == 0);
423 assert!(!toml.join("settings.json").exists());
424 let _ = std::fs::remove_dir_all(&dir);
425 }
426
427 #[cfg(unix)]
428 #[test]
429 fn context_files_and_symlinks() {
430 use std::os::unix::fs::symlink;
431 let dir = std::env::temp_dir().join(format!("yapi-import-links-{}", std::process::id()));
432 let _ = std::fs::remove_dir_all(&dir);
433 let (pi, yapi) = (dir.join("pi"), dir.join("yapi"));
434 write(&pi, "AGENTS.md", "global rules\n");
435 write(&pi, "skills/real/SKILL.md", "skill\n");
436 symlink(pi.join("nowhere"), pi.join("skills/dangling")).unwrap();
437 symlink(pi.join("skills/loop-b"), pi.join("skills/loop-a")).unwrap();
438 symlink(pi.join("skills/loop-a"), pi.join("skills/loop-b")).unwrap();
439 symlink(pi.join("skills"), pi.join("skills/real/up")).unwrap();
440 symlink(pi.join("skills/real"), pi.join("skills/alias")).unwrap();
441
442 let imported = import(&pi, &yapi, AGENT_ENTRIES).unwrap();
443
444 let summary: Vec<(&str, usize)> = imported
447 .iter()
448 .filter(|entry| entry.copied > 0)
449 .map(|entry| (entry.entry.as_str(), entry.copied))
450 .collect();
451 assert_eq!(summary, [("AGENTS.md", 1), ("skills", 2)]);
452 assert!(yapi.join("skills/alias/SKILL.md").is_file());
453 assert!(!yapi.join("skills/dangling").exists());
454 let _ = std::fs::remove_dir_all(&dir);
455 }
456}