use std::{
fs, io,
path::{Component, Path, PathBuf},
};
use anyhow::{Context, Result};
use crate::{
launch::Tool,
profile::{DEFAULT_PROFILE, Profile, Store},
tools::{self, Home},
};
const DISPLACED: &str = "before-ditto";
const CLAUDE: &[&str] = &[
"skills",
"agents",
"commands",
"hooks",
"plugins",
"output-styles",
"CLAUDE.md",
];
const CODEX: &[&str] = &[
"skills",
"rules",
"prompts",
"plugins",
"config.toml",
"hooks.json",
"AGENTS.md",
"instructions.md",
];
const FX: &[&str] = &["settings.json", "AGENTS.md", "skills", "memories.json"];
const OMP: &[&str] = &["config.yml", "extensions"];
const PRIME_AGENT: &[&str] = &[
"settings.json",
"keybindings.json",
"AGENTS.md",
"CLAUDE.md",
"SYSTEM.md",
"APPEND_SYSTEM.md",
"prompts",
"skills",
"extensions",
"themes",
"git",
"harness",
];
const PI: &[&str] = &[
"settings.json",
"keybindings.json",
"trust.json",
"AGENTS.md",
"CLAUDE.md",
"SYSTEM.md",
"APPEND_SYSTEM.md",
"prompts",
"skills",
"extensions",
"themes",
"git",
"npm",
"bin",
];
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct Linked {
pub linked: Vec<String>,
pub kept: Vec<String>,
pub failed: Vec<(String, String)>,
changed: bool,
}
impl Linked {
pub fn changed(&self) -> bool {
self.changed
}
}
struct Borrowed {
label: String,
from: PathBuf,
into: PathBuf,
}
pub fn link(source: &Profile, target: &Profile, adopt: bool) -> Result<Linked> {
let mut result = Linked::default();
let holds_profile = target.directories();
mirror_home(
"fx/home",
&source.fx_home,
&target.fx_home,
&[".fx"],
&holds_profile,
&mut result,
);
for spec in tools::ALL {
if let Home::Private { native, owned } = spec.home {
let keep = std::iter::once(native)
.chain(owned.iter().copied())
.collect::<Vec<_>>();
mirror_home(
&format!("{}/home", spec.key),
source.tool_home(spec),
target.tool_home(spec),
&keep,
&holds_profile,
&mut result,
);
}
}
for borrowed in plan(source, target) {
if !borrowed.from.exists() {
continue;
}
match attach(&borrowed, adopt) {
Ok(Outcome::Created) => {
result.linked.push(borrowed.label);
result.changed = true;
}
Ok(Outcome::Already) => result.linked.push(borrowed.label),
Ok(Outcome::Kept) => result.kept.push(borrowed.label),
Err(error) => result.failed.push((borrowed.label, format!("{error:#}"))),
}
}
Ok(result)
}
pub fn seed(store: &Store, profile: &Profile) -> Linked {
match store.load_profile(DEFAULT_PROFILE) {
Ok(source) => link(&source, profile, false).unwrap_or_default(),
Err(_) => Linked::default(),
}
}
fn mirror_home(
label: &str,
from: &Path,
into: &Path,
keep: &[&str],
holds_profile: &[&Path],
result: &mut Linked,
) {
if from == into {
return;
}
let entries = match fs::read_dir(from) {
Ok(entries) => entries,
Err(error) => {
result
.failed
.push((label.to_owned(), format!("could not read home: {error}")));
return;
}
};
let mut visible = false;
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
result.failed.push((
label.to_owned(),
format!("could not read a home entry: {error}"),
));
continue;
}
};
if holds_profile
.iter()
.any(|directory| directory.starts_with(entry.path()))
{
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
if keep.contains(&name.as_str()) {
continue;
}
let entry_label = format!("{label}/{name}");
let nested = keep
.iter()
.filter_map(|kept| kept.strip_prefix(name.as_str())?.strip_prefix('/'))
.collect::<Vec<_>>();
if !nested.is_empty() {
let inner = into.join(entry.file_name());
match fs::create_dir_all(&inner) {
Ok(()) => mirror_home(
&entry_label,
&entry.path(),
&inner,
&nested,
holds_profile,
result,
),
Err(error) => result
.failed
.push((entry_label, format!("could not create: {error}"))),
}
continue;
}
let borrowed = Borrowed {
label: entry_label.clone(),
from: entry.path(),
into: into.join(entry.file_name()),
};
match attach(&borrowed, false) {
Ok(Outcome::Created) => {
visible = true;
result.changed = true;
}
Ok(Outcome::Already) => visible = true,
Ok(Outcome::Kept) => {}
Err(error) => result.failed.push((entry_label, format!("{error:#}"))),
}
}
if visible {
result.linked.push(label.to_owned());
}
}
fn plan(source: &Profile, target: &Profile) -> Vec<Borrowed> {
paths(source)
.into_iter()
.zip(paths(target))
.map(|(borrowed, own)| Borrowed {
label: borrowed.label,
from: borrowed.path,
into: own.path,
})
.collect()
}
struct Owned {
tool: Tool,
label: String,
path: PathBuf,
}
fn paths(profile: &Profile) -> Vec<Owned> {
let mut paths = Vec::new();
for name in CLAUDE {
paths.push(Owned {
tool: Tool::Claude,
label: format!("claude/{name}"),
path: profile.claude_home.join(name),
});
}
for name in CODEX {
paths.push(Owned {
tool: Tool::Codex,
label: format!("codex/{name}"),
path: profile.codex_home.join(name),
});
}
for name in FX {
paths.push(Owned {
tool: Tool::Fx,
label: format!("fx/{name}"),
path: profile.fx_dir().join(name),
});
}
paths.push(Owned {
tool: Tool::Opencode,
label: "opencode/config".to_owned(),
path: profile.opencode.config_dir(),
});
for name in OMP {
paths.push(Owned {
tool: Tool::Omp,
label: format!("omp/{name}"),
path: profile.omp_home.join(name),
});
}
for name in PRIME_AGENT {
paths.push(Owned {
tool: Tool::PrimeAgent,
label: format!("prime-agent/{name}"),
path: profile.prime_agent_home.join(name),
});
}
for name in PI {
paths.push(Owned {
tool: Tool::Pi,
label: format!("pi/{name}"),
path: profile.pi_home.join(name),
});
}
for spec in tools::ALL {
for name in spec.shared {
paths.push(Owned {
tool: Tool::Generic(spec),
label: format!("{}/{name}", spec.key),
path: profile.tool_path(spec, name),
});
}
}
paths
}
const SEARCH_DEPTH: usize = 3;
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct Repaired {
pub links: Vec<String>,
pub failed: Vec<(String, String)>,
}
impl Repaired {
pub fn changed(&self) -> bool {
!self.links.is_empty()
}
}
pub fn repair(profile: &Profile) -> Repaired {
mend(paths(profile))
}
pub fn repair_for(tool: Tool, profile: &Profile) -> Repaired {
mend(
paths(profile)
.into_iter()
.filter(|owned| owned.tool == tool),
)
}
fn mend(paths: impl IntoIterator<Item = Owned>) -> Repaired {
let mut result = Repaired::default();
for owned in paths {
if fs::symlink_metadata(&owned.path).is_ok_and(|entry| entry.is_symlink()) {
search(&owned.label, &owned.path, SEARCH_DEPTH, &mut result);
}
}
result
}
fn search(label: &str, directory: &Path, depth: usize, result: &mut Repaired) {
let Ok(entries) = fs::read_dir(directory) else {
return;
};
for entry in entries.flatten() {
let Ok(kind) = entry.file_type() else {
continue;
};
let path = entry.path();
let label = format!("{label}/{}", entry.file_name().to_string_lossy());
if kind.is_symlink() {
let Some(meant) = intended(&path) else {
continue;
};
match relink(&path, &meant) {
Ok(()) => result.links.push(label),
Err(error) => result.failed.push((label, format!("{error:#}"))),
}
} else if kind.is_dir() && depth > 1 {
search(&label, &path, depth - 1, result);
}
}
}
fn intended(link: &Path) -> Option<PathBuf> {
let target = fs::read_link(link).ok()?;
if target.is_absolute() {
return None;
}
if link.exists() {
return None;
}
let meant = resolve(&link.parent()?.join(target));
meant.exists().then_some(meant)
}
fn resolve(path: &Path) -> PathBuf {
let mut resolved = PathBuf::new();
for component in path.components() {
match component {
Component::ParentDir => {
resolved.pop();
}
Component::CurDir => {}
component => resolved.push(component),
}
}
resolved
}
fn relink(link: &Path, target: &Path) -> Result<()> {
remove_link(link).with_context(|| format!("could not replace {}", link.display()))?;
symlink(target, link)
}
#[cfg(unix)]
fn remove_link(link: &Path) -> io::Result<()> {
fs::remove_file(link)
}
#[cfg(windows)]
fn remove_link(link: &Path) -> io::Result<()> {
fs::remove_file(link).or_else(|_| fs::remove_dir(link))
}
enum Outcome {
Created,
Already,
Kept,
}
fn attach(borrowed: &Borrowed, adopt: bool) -> Result<Outcome> {
match fs::symlink_metadata(&borrowed.into) {
Err(error) if error.kind() == io::ErrorKind::NotFound => {
symlink(&borrowed.from, &borrowed.into)?;
Ok(Outcome::Created)
}
Err(error) => {
Err(error).with_context(|| format!("could not inspect {}", borrowed.into.display()))
}
Ok(existing) if existing.is_symlink() => {
let points_at = fs::read_link(&borrowed.into)
.with_context(|| format!("could not read {}", borrowed.into.display()))?;
if points_at == borrowed.from {
return Ok(Outcome::Already);
}
if !adopt {
return Ok(Outcome::Kept);
}
fs::remove_file(&borrowed.into)
.with_context(|| format!("could not replace {}", borrowed.into.display()))?;
symlink(&borrowed.from, &borrowed.into)?;
Ok(Outcome::Created)
}
Ok(_) if !adopt => Ok(Outcome::Kept),
Ok(_) => {
displace(&borrowed.into)?;
symlink(&borrowed.from, &borrowed.into)?;
Ok(Outcome::Created)
}
}
}
fn displace(path: &Path) -> Result<()> {
let parent = path
.parent()
.with_context(|| format!("{} has no parent directory", path.display()))?;
let name = path
.file_name()
.with_context(|| format!("{} does not name a file", path.display()))?
.to_string_lossy()
.into_owned();
for attempt in 0.. {
let suffix = if attempt == 0 {
String::new()
} else {
format!(".{attempt}")
};
let aside = parent.join(format!("{name}.{DISPLACED}{suffix}"));
if aside.exists() {
continue;
}
return fs::rename(path, &aside)
.with_context(|| format!("could not move {} to {}", path.display(), aside.display()));
}
unreachable!("the loop returns on the first name that is free")
}
#[cfg(unix)]
fn symlink(from: &Path, into: &Path) -> Result<()> {
std::os::unix::fs::symlink(from, into)
.with_context(|| format!("could not link {} to {}", into.display(), from.display()))
}
#[cfg(windows)]
fn symlink(from: &Path, into: &Path) -> Result<()> {
let result = if from.is_dir() {
std::os::windows::fs::symlink_dir(from, into)
} else {
std::os::windows::fs::symlink_file(from, into)
};
result.with_context(|| {
format!(
"could not link {} to {} (Windows allows this to an administrator, \
or to any account with Developer Mode turned on)",
into.display(),
from.display()
)
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn store(root: &Path) -> Store {
Store::new(root.join("ditto"), root.join("home"))
}
fn given_directory(path: &Path, file: &str) {
fs::create_dir_all(path).unwrap();
fs::write(path.join(file), "yours").unwrap();
}
fn relative(from: &Path, to: &Path) -> PathBuf {
let mut from = from.components().peekable();
let mut to = to.components().peekable();
while from.peek().is_some() && from.peek() == to.peek() {
from.next();
to.next();
}
let mut relative = PathBuf::new();
for _ in from {
relative.push("..");
}
relative.extend(to);
relative
}
fn installed_through(handed: &Path, name: &str, skill: &Path) {
symlink(&relative(handed, skill), &handed.join(name)).unwrap();
}
#[test]
fn a_profile_reads_the_skills_you_already_had() {
let temporary = tempdir().unwrap();
let store = store(temporary.path());
let source = store.load_profile(DEFAULT_PROFILE).unwrap();
given_directory(&source.claude_home.join("skills"), "humanizer.md");
let target = store.create_profile("work").unwrap();
let linked = link(&source, &target, false).unwrap();
assert!(linked.changed());
assert!(linked.linked.contains(&"claude/skills".to_owned()));
assert_eq!(
fs::read_to_string(target.claude_home.join("skills/humanizer.md")).unwrap(),
"yours"
);
}
#[test]
fn a_skill_added_later_shows_up_without_syncing() {
let temporary = tempdir().unwrap();
let store = store(temporary.path());
let source = store.load_profile(DEFAULT_PROFILE).unwrap();
given_directory(&source.claude_home.join("skills"), "first.md");
let target = store.create_profile("work").unwrap();
link(&source, &target, false).unwrap();
fs::write(source.claude_home.join("skills/second.md"), "later").unwrap();
assert_eq!(
fs::read_to_string(target.claude_home.join("skills/second.md")).unwrap(),
"later"
);
}
#[test]
fn linking_twice_changes_nothing_the_second_time() {
let temporary = tempdir().unwrap();
let store = store(temporary.path());
let source = store.load_profile(DEFAULT_PROFILE).unwrap();
given_directory(&source.claude_home.join("skills"), "one.md");
let target = store.create_profile("work").unwrap();
assert!(link(&source, &target, false).unwrap().changed());
let again = link(&source, &target, false).unwrap();
assert!(!again.changed());
assert!(again.linked.contains(&"claude/skills".to_owned()));
}
#[test]
fn leaves_a_directory_the_profile_already_has() {
let temporary = tempdir().unwrap();
let store = store(temporary.path());
let source = store.load_profile(DEFAULT_PROFILE).unwrap();
given_directory(&source.claude_home.join("plugins"), "yours.json");
let target = store.create_profile("work").unwrap();
given_directory(&target.claude_home.join("plugins"), "theirs.json");
let linked = link(&source, &target, false).unwrap();
assert!(linked.kept.contains(&"claude/plugins".to_owned()));
assert!(target.claude_home.join("plugins/theirs.json").exists());
}
#[test]
fn adopting_keeps_what_it_moved_aside() {
let temporary = tempdir().unwrap();
let store = store(temporary.path());
let source = store.load_profile(DEFAULT_PROFILE).unwrap();
given_directory(&source.claude_home.join("plugins"), "yours.json");
let target = store.create_profile("work").unwrap();
given_directory(&target.claude_home.join("plugins"), "theirs.json");
let linked = link(&source, &target, true).unwrap();
assert!(linked.linked.contains(&"claude/plugins".to_owned()));
assert!(target.claude_home.join("plugins/yours.json").exists());
assert!(
target
.claude_home
.join(format!("plugins.{DISPLACED}/theirs.json"))
.exists()
);
}
#[test]
fn shares_nothing_you_do_not_have() {
let temporary = tempdir().unwrap();
let store = store(temporary.path());
let source = store.load_profile(DEFAULT_PROFILE).unwrap();
let target = store.create_profile("work").unwrap();
let linked = link(&source, &target, false).unwrap();
assert!(!linked.changed());
assert!(linked.linked.is_empty());
assert!(!target.claude_home.join("skills").exists());
}
#[test]
fn never_shares_anything_holding_an_account() {
let account = [
".claude.json",
"sessions",
"projects",
"history.jsonl",
"auth.json",
"chatgpt-auth.json",
"grok-auth.json",
"api-key",
"mcp.json",
"mcp-credentials",
"oauth.json",
"account.json",
"agent.db",
"state",
"session-artifacts",
"session-leases",
"cron-jobs.json",
"models.json",
];
for name in account {
assert!(
!CLAUDE.contains(&name)
&& !CODEX.contains(&name)
&& !FX.contains(&name)
&& !OMP.contains(&name)
&& !PRIME_AGENT.contains(&name)
&& !PI.contains(&name)
&& !tools::ALL.iter().any(|spec| spec.shared.contains(&name)),
"'{name}' carries an account and must not be shared between profiles"
);
}
}
#[test]
fn a_pi_profile_reads_the_global_skills() {
let temporary = tempdir().unwrap();
let store = store(temporary.path());
let source = store.load_profile(DEFAULT_PROFILE).unwrap();
given_directory(&source.pi_home.join("skills"), "profile-test.md");
let target = store.create_profile("work").unwrap();
let linked = link(&source, &target, false).unwrap();
assert!(linked.linked.contains(&"pi/skills".to_owned()));
assert_eq!(
fs::read_to_string(target.pi_home.join("skills/profile-test.md")).unwrap(),
"yours"
);
}
#[test]
fn a_prime_agent_profile_reads_the_global_harness() {
let temporary = tempdir().unwrap();
let store = store(temporary.path());
let source = store.load_profile(DEFAULT_PROFILE).unwrap();
given_directory(
&source.prime_agent_home.join("harness"),
"harness_state.json",
);
let target = store.create_profile("work").unwrap();
let linked = link(&source, &target, false).unwrap();
assert!(linked.linked.contains(&"prime-agent/harness".to_owned()));
assert_eq!(
fs::read_to_string(target.prime_agent_home.join("harness/harness_state.json")).unwrap(),
"yours"
);
}
#[test]
fn a_link_installed_through_ours_is_pointed_back_at_the_skill() {
let temporary = tempdir().unwrap();
let store = store(temporary.path());
let source = store.load_profile(DEFAULT_PROFILE).unwrap();
given_directory(&source.claude_home.join("skills"), "yours.md");
let skill = temporary.path().join("home/.agents/skills/apple-design");
given_directory(&skill, "SKILL.md");
let target = store.create_profile("work").unwrap();
link(&source, &target, false).unwrap();
let handed = target.claude_home.join("skills");
installed_through(&handed, "apple-design", &skill);
assert!(!handed.join("apple-design").exists());
let repaired = repair(&target);
assert!(repaired.changed());
assert_eq!(repaired.links, ["claude/skills/apple-design"]);
assert_eq!(
fs::read_to_string(handed.join("apple-design/SKILL.md")).unwrap(),
"yours"
);
assert!(
source
.claude_home
.join("skills/apple-design/SKILL.md")
.exists()
);
}
#[test]
fn repairs_a_link_written_below_the_directory_a_tool_was_given() {
let temporary = tempdir().unwrap();
let store = store(temporary.path());
let source = store.load_profile(DEFAULT_PROFILE).unwrap();
given_directory(&source.opencode.config_dir(), "opencode.json");
let skill = temporary.path().join("home/.agents/skills/apple-design");
given_directory(&skill, "SKILL.md");
let target = store.create_profile("work").unwrap();
link(&source, &target, false).unwrap();
let handed = target.opencode.config_dir().join("skills");
fs::create_dir_all(&handed).unwrap();
installed_through(&handed, "apple-design", &skill);
let repaired = repair(&target);
assert_eq!(repaired.links, ["opencode/config/skills/apple-design"]);
assert!(handed.join("apple-design/SKILL.md").exists());
}
#[test]
fn repairs_only_the_tool_a_launch_is_about_to_start() {
let temporary = tempdir().unwrap();
let store = store(temporary.path());
let source = store.load_profile(DEFAULT_PROFILE).unwrap();
given_directory(&source.claude_home.join("skills"), "yours.md");
given_directory(&source.codex_home.join("skills"), "yours.md");
let skill = temporary.path().join("home/.agents/skills/apple-design");
given_directory(&skill, "SKILL.md");
let target = store.create_profile("work").unwrap();
link(&source, &target, false).unwrap();
let claude = target.claude_home.join("skills");
let codex = target.codex_home.join("skills");
installed_through(&claude, "apple-design", &skill);
installed_through(&codex, "apple-design", &skill);
let repaired = repair_for(Tool::Codex, &target);
assert_eq!(repaired.links, ["codex/skills/apple-design"]);
assert!(codex.join("apple-design").exists());
assert!(!claude.join("apple-design").exists());
}
#[test]
fn leaves_a_link_that_already_reads_correctly_alone() {
let temporary = tempdir().unwrap();
let store = store(temporary.path());
let source = store.load_profile(DEFAULT_PROFILE).unwrap();
let skills = source.claude_home.join("skills");
given_directory(&skills, "yours.md");
let skill = temporary.path().join("home/.agents/skills/apple-design");
given_directory(&skill, "SKILL.md");
installed_through(&skills, "apple-design", &skill);
let target = store.create_profile("work").unwrap();
link(&source, &target, false).unwrap();
let repaired = repair(&target);
assert!(!repaired.changed());
assert_eq!(
fs::read_link(skills.join("apple-design")).unwrap(),
relative(&skills, &skill)
);
}
#[test]
fn leaves_broken_links_it_did_not_cause_alone() {
let temporary = tempdir().unwrap();
let store = store(temporary.path());
let source = store.load_profile(DEFAULT_PROFILE).unwrap();
given_directory(&source.claude_home.join("skills"), "yours.md");
let target = store.create_profile("work").unwrap();
link(&source, &target, false).unwrap();
let handed = target.claude_home.join("skills");
symlink(Path::new("/nowhere/at/all"), &handed.join("absolute")).unwrap();
symlink(Path::new("../../nowhere"), &handed.join("relative")).unwrap();
let repaired = repair(&target);
assert!(!repaired.changed());
assert!(repaired.failed.is_empty());
assert_eq!(
fs::read_link(handed.join("absolute")).unwrap(),
Path::new("/nowhere/at/all")
);
assert_eq!(
fs::read_link(handed.join("relative")).unwrap(),
Path::new("../../nowhere")
);
}
#[test]
fn leaves_a_directory_the_profile_owns_alone() {
let temporary = tempdir().unwrap();
let store = store(temporary.path());
let source = store.load_profile(DEFAULT_PROFILE).unwrap();
given_directory(&source.claude_home.join("skills"), "yours.md");
let skill = temporary.path().join("home/.agents/skills/apple-design");
given_directory(&skill, "SKILL.md");
let target = store.create_profile("work").unwrap();
let handed = target.claude_home.join("skills");
given_directory(&handed, "theirs.md");
link(&source, &target, false).unwrap();
symlink(Path::new("../../nowhere"), &handed.join("apple-design")).unwrap();
assert!(!repair(&target).changed());
assert_eq!(
fs::read_link(handed.join("apple-design")).unwrap(),
Path::new("../../nowhere")
);
}
#[test]
fn deleting_a_profile_leaves_your_own_configuration_alone() {
let temporary = tempdir().unwrap();
let store = store(temporary.path());
let source = store.load_profile(DEFAULT_PROFILE).unwrap();
given_directory(&source.claude_home.join("skills"), "humanizer.md");
let target = store.create_profile("work").unwrap();
link(&source, &target, false).unwrap();
store.delete_profile("work").unwrap();
assert!(source.claude_home.join("skills/humanizer.md").exists());
}
}