Skip to main content

yapi_core/
import.rs

1//! `yapi import pi`: copies pi's state into yapi's directories. yapi reads pi's
2//! formats unchanged, so files are copied as they are, except pi's
3//! `lastChangelogVersion` and sign-ins. Files yapi already has are kept.
4
5use std::path::{Path, PathBuf};
6
7use serde_json::{Map, Value};
8use yapi_types::config::ConfigFile;
9
10/// The setting in which pi and yapi each record their own version.
11const CHANGELOG_VERSION: &str = "lastChangelogVersion";
12
13/// The agent directory entries yapi reads, in the order they are copied.
14pub 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
38/// The project directory (`.pi`) entries yapi reads, in the order they are
39/// copied.
40pub 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/// What happened to one entry.
54#[derive(Clone, Debug, Default, PartialEq, Eq)]
55pub struct Imported {
56    /// The entry's name.
57    pub entry: String,
58    /// Files copied.
59    pub copied: usize,
60    /// Files not copied because yapi already had them, or the installed
61    /// package that holds them, which it keeps as they are.
62    pub kept: usize,
63    /// Sign-ins left out of a copied `auth.json` (providers) or
64    /// `mcp-auth.json` (MCP servers). Servers may rotate refresh tokens, so
65    /// copies in pi and yapi would sign each other out.
66    pub signed_out: Vec<String>,
67}
68
69/// pi's agent directory: `PI_CODING_AGENT_DIR`, or `~/.pi/agent`.
70pub 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
77/// An I/O error naming the path it concerns.
78fn at(path: &Path, error: std::io::Error) -> std::io::Error {
79    std::io::Error::new(error.kind(), format!("{}: {error}", path.display()))
80}
81
82/// Whether `dir` holds one installed package: a git checkout, or a package's
83/// folder in `node_modules`.
84fn 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
98/// The files in `dir` and the folders under it, links counted as files.
99fn 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
109/// Copies `from` to `to`, following symbolic links. Links that lead nowhere,
110/// loop, or lead back into a directory being copied are skipped. With
111/// `packages`, a package `to` already has keeps its own files and gets none
112/// of `from`'s, whose files count as kept.
113fn 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    // `copy` keeps the permissions, so credentials stay private.
161    std::fs::copy(from, to).map_err(|error| at(from, error))?;
162    imported.copied += 1;
163    Ok(())
164}
165
166/// The settings or credentials document at `path`, if it is a JSON object.
167fn settings(path: &Path) -> Option<Map<String, Value>> {
168    serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
169}
170
171/// Removes the sign-ins from the copied credentials `file` at `path`: OAuth
172/// credentials of `auth.json`, servers holding tokens in `mcp-auth.json`.
173/// Returns their providers or server names. A file without any is untouched.
174fn 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        // MCP keys are `mcp__<server>|<url>`, or the URL alone in older files.
186        .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
205/// Copies `entries` of directory `from` into `to`, keeping files `to`
206/// already has, and in `npm` and `git` the whole packages it already has, so
207/// two installs never mix. Entries `from` lacks are skipped. A `settings.json` that
208/// holds only the version yapi recorded on its first start is replaced, and
209/// pi's `lastChangelogVersion`, a pi version, is not copied. Nor are the
210/// sign-ins of a copied `auth.json` or `mcp-auth.json` (see
211/// [`Imported::signed_out`]).
212pub 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        // Packages yapi has keep their own files whole.
282        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        // What yapi's first start writes.
382        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        // Settings with anything else are kept as before.
396        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        // On a case-insensitive file system `AGENTS.MD` names the same file
429        // and is kept.
430        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}