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    /// Whether pi's `settings.json` stayed out because yapi's folder holds
68    /// `settings.toml`, which a second settings file would conflict with.
69    pub beside_toml: bool,
70}
71
72/// pi's agent directory: `PI_CODING_AGENT_DIR`, or `~/.pi/agent`.
73pub 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
80/// An I/O error naming the path it concerns.
81fn at(path: &Path, error: std::io::Error) -> std::io::Error {
82    std::io::Error::new(error.kind(), format!("{}: {error}", path.display()))
83}
84
85/// Whether `dir` holds one installed package: a git checkout, or a package's
86/// folder in `node_modules`.
87fn 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
101/// The files in `dir` and the folders under it, links counted as files.
102fn 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
112/// Copies `from` to `to`, following symbolic links. Links that lead nowhere,
113/// loop, or lead back into a directory being copied are skipped. With
114/// `packages`, a package `to` already has keeps its own files and gets none
115/// of `from`'s, whose files count as kept.
116fn 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    // `copy` keeps the permissions, so credentials stay private.
164    std::fs::copy(from, to).map_err(|error| at(from, error))?;
165    imported.copied += 1;
166    Ok(())
167}
168
169/// The settings or credentials document at `path`, if it is a JSON object.
170fn settings(path: &Path) -> Option<Map<String, Value>> {
171    serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
172}
173
174/// Removes the sign-ins from the copied credentials `file` at `path`: OAuth
175/// credentials of `auth.json`, servers holding tokens in `mcp-auth.json`.
176/// Returns their providers or server names. A file without any is untouched.
177fn 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        // MCP keys are `mcp__<server>|<url>`, or the URL alone in older files.
189        .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
208/// Copies `entries` of directory `from` into `to`, keeping files `to`
209/// already has, and in `npm` and `git` the whole packages it already has, so
210/// two installs never mix. Entries `from` lacks are skipped. A `settings.json` that
211/// holds only the version yapi recorded on its first start is replaced, and
212/// pi's `lastChangelogVersion`, a pi version, is not copied. Nor are the
213/// sign-ins of a copied `auth.json` or `mcp-auth.json` (see
214/// [`Imported::signed_out`]). pi's `settings.json` stays out of a folder
215/// that holds `settings.toml`.
216pub 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        // Packages yapi has keep their own files whole.
291        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        // What yapi's first start writes.
391        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        // Settings with anything else are kept as before.
405        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        // Beside a settings.toml, pi's settings.json stays out.
419        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        // On a case-insensitive file system `AGENTS.MD` names the same file
445        // and is kept.
446        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}