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