Skip to main content

ljos_cli/
offboard.rs

1//! Undo runner files written by `ljos onboard`.
2//!
3//! Onboarding records each changed file, its previous bytes, and the bytes
4//! it installed. Offboarding restores an unchanged installed file exactly.
5//! A later edit is reversed only when the installed part can be identified.
6//! Older installs have no record, so their runner paths are cleaned by the
7//! markers ljos wrote into skills, hooks, plugins, env files and MCP entries.
8
9use std::collections::{BTreeMap, BTreeSet};
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use anyhow::{Context, Result};
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16use sha2::{Digest, Sha256};
17
18use crate::{Harness, Harnesses, OnboardPlace, Step};
19
20const VERSION: u32 = 1;
21
22#[derive(Debug, Default, Deserialize, Serialize)]
23struct Manifest {
24    version: u32,
25    entries: Vec<Entry>,
26}
27
28#[derive(Debug, Clone, Deserialize, Serialize)]
29struct Entry {
30    runner: String,
31    path: PathBuf,
32    before: Option<String>,
33    after: Option<String>,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    before_mode: Option<u32>,
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    after_mode: Option<u32>,
38}
39
40#[derive(Clone)]
41struct FileState {
42    bytes: Vec<u8>,
43    mode: Option<u32>,
44}
45
46pub(crate) struct Journal {
47    runner: String,
48    file: PathBuf,
49    skills: Option<PathBuf>,
50    place: Option<OwnedPlace>,
51    before: BTreeMap<PathBuf, Option<FileState>>,
52}
53
54#[derive(Clone)]
55struct OwnedPlace {
56    hooks_dir: PathBuf,
57    guard_dir: PathBuf,
58    git_config: Option<PathBuf>,
59    git_env: Vec<(String, String)>,
60}
61
62impl OwnedPlace {
63    fn from(place: OnboardPlace<'_>) -> Self {
64        let git_config = place
65            .git_env
66            .iter()
67            .find_map(|(key, value)| (*key == "GIT_CONFIG_GLOBAL").then(|| PathBuf::from(value)));
68        Self {
69            hooks_dir: place.hooks_dir.to_path_buf(),
70            guard_dir: place.guard_dir.to_path_buf(),
71            git_config,
72            git_env: place
73                .git_env
74                .iter()
75                .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
76                .collect(),
77        }
78    }
79}
80
81impl Journal {
82    pub(crate) fn begin(
83        file: &Path,
84        runner: &str,
85        skills: Option<&Path>,
86        place: Option<OnboardPlace<'_>>,
87        dry: bool,
88    ) -> Option<Self> {
89        if dry || runner == "json" {
90            return None;
91        }
92        let place = place.map(OwnedPlace::from);
93        let paths = candidate_paths(file, runner, skills, place.as_ref());
94        let before = paths
95            .into_iter()
96            .map(|path| {
97                let state = read_state(&path);
98                (path, state)
99            })
100            .collect();
101        Some(Self {
102            runner: runner.to_string(),
103            file: file.to_path_buf(),
104            skills: skills.map(Path::to_path_buf),
105            place,
106            before,
107        })
108    }
109
110    pub(crate) fn finish(mut self) -> Result<()> {
111        for path in candidate_paths(
112            &self.file,
113            &self.runner,
114            self.skills.as_deref(),
115            self.place.as_ref(),
116        ) {
117            self.before.entry(path).or_insert(None);
118        }
119        let mut manifest = match read_manifest() {
120            Ok(manifest) => manifest,
121            Err(error) if missing_manifest(&error) => Manifest::default(),
122            Err(error) => return Err(error),
123        };
124        manifest.version = VERSION;
125        for (path, before) in self.before {
126            let after = read_state(&path);
127            if same_state(before.as_ref(), after.as_ref()) {
128                continue;
129            }
130            let before_name = before
131                .as_ref()
132                .map(|state| store_backup(&state.bytes))
133                .transpose()?;
134            let after_name = after
135                .as_ref()
136                .map(|state| store_backup(&state.bytes))
137                .transpose()?;
138            manifest.entries.push(Entry {
139                runner: self.runner.clone(),
140                path,
141                before: before_name,
142                after: after_name,
143                before_mode: before.as_ref().and_then(|state| state.mode),
144                after_mode: after.as_ref().and_then(|state| state.mode),
145            });
146        }
147        write_manifest(&manifest)
148    }
149}
150
151fn state_root() -> PathBuf {
152    std::env::var_os("XDG_STATE_HOME")
153        .filter(|value| !value.is_empty())
154        .map(PathBuf::from)
155        .or_else(|| {
156            std::env::var_os("HOME")
157                .filter(|value| !value.is_empty())
158                .map(|home| PathBuf::from(home).join(".local/state"))
159        })
160        .unwrap_or_else(|| PathBuf::from(".local/state"))
161        .join("ljos")
162        .join("onboard")
163}
164
165fn manifest_path() -> PathBuf {
166    state_root().join("manifest.json")
167}
168
169fn backup_dir() -> PathBuf {
170    state_root().join("backups")
171}
172
173fn read_manifest() -> Result<Manifest> {
174    let path = manifest_path();
175    let text = fs::read_to_string(&path).with_context(|| path.display().to_string())?;
176    let manifest: Manifest =
177        serde_json::from_str(&text).with_context(|| format!("{} is not JSON", path.display()))?;
178    Ok(manifest)
179}
180
181fn write_manifest(manifest: &Manifest) -> Result<()> {
182    let path = manifest_path();
183    if manifest.entries.is_empty() {
184        let _ = fs::remove_file(&path);
185        let _ = fs::remove_dir_all(backup_dir());
186        remove_empty_parents(path.parent(), state_root().parent());
187        return Ok(());
188    }
189    if let Some(parent) = path.parent() {
190        fs::create_dir_all(parent)?;
191    }
192    let mut text = serde_json::to_string_pretty(manifest)?;
193    text.push('\n');
194    let temporary = path.with_extension("json.tmp");
195    fs::write(&temporary, text)?;
196    fs::rename(temporary, path)?;
197    Ok(())
198}
199
200fn store_backup(bytes: &[u8]) -> Result<String> {
201    let name = hex::encode(Sha256::digest(bytes));
202    let path = backup_dir().join(&name);
203    if !path.is_file() {
204        fs::create_dir_all(backup_dir())?;
205        fs::write(&path, bytes)?;
206    }
207    Ok(name)
208}
209
210fn load_backup(name: &str) -> Result<Vec<u8>> {
211    let path = backup_dir().join(name);
212    fs::read(&path).with_context(|| format!("missing onboard backup {}", path.display()))
213}
214
215fn read_state(path: &Path) -> Option<FileState> {
216    let bytes = fs::read(path).ok()?;
217    Some(FileState {
218        bytes,
219        mode: file_mode(path),
220    })
221}
222
223#[cfg(unix)]
224fn file_mode(path: &Path) -> Option<u32> {
225    use std::os::unix::fs::PermissionsExt;
226    fs::metadata(path)
227        .ok()
228        .map(|meta| meta.permissions().mode())
229}
230
231#[cfg(not(unix))]
232fn file_mode(_path: &Path) -> Option<u32> {
233    None
234}
235
236#[cfg(unix)]
237fn set_mode(path: &Path, mode: Option<u32>) -> std::io::Result<()> {
238    use std::os::unix::fs::PermissionsExt;
239    if let Some(mode) = mode {
240        fs::set_permissions(path, fs::Permissions::from_mode(mode))?;
241    }
242    Ok(())
243}
244
245#[cfg(not(unix))]
246fn set_mode(_path: &Path, _mode: Option<u32>) -> std::io::Result<()> {
247    Ok(())
248}
249
250fn same_state(left: Option<&FileState>, right: Option<&FileState>) -> bool {
251    match (left, right) {
252        (None, None) => true,
253        (Some(left), Some(right)) => left.bytes == right.bytes && left.mode == right.mode,
254        _ => false,
255    }
256}
257
258fn harness_for(file: &Path, runner: &str) -> Option<Harness> {
259    crate::harnesses_with_shipped(file)
260        .ok()
261        .and_then(|all| all.harness.into_iter().find(|h| h.name == runner))
262        .or_else(|| {
263            toml::from_str::<Harnesses>(crate::HARNESSES_EXAMPLE)
264                .ok()?
265                .harness
266                .into_iter()
267                .find(|h| h.name == runner)
268        })
269}
270
271fn add_tree_files(paths: &mut BTreeSet<PathBuf>, dir: &Path, predicate: impl Fn(&Path) -> bool) {
272    let Ok(entries) = fs::read_dir(dir) else {
273        return;
274    };
275    for entry in entries.flatten() {
276        let path = entry.path();
277        if path.is_file() && predicate(&path) {
278            paths.insert(path);
279        }
280    }
281}
282
283fn candidate_paths(
284    file: &Path,
285    runner: &str,
286    skills: Option<&Path>,
287    place: Option<&OwnedPlace>,
288) -> BTreeSet<PathBuf> {
289    let mut paths = BTreeSet::from([file.to_path_buf()]);
290    if runner == "aider" {
291        if let Ok(home) = std::env::var("HOME") {
292            paths.insert(PathBuf::from(&home).join(".aider/ljos-conventions.md"));
293            paths.insert(PathBuf::from(home).join(".aider.conf.yml"));
294        }
295        return paths;
296    }
297    if runner == "grok" {
298        paths.insert(crate::expand("~/.grok/hooks/ljos.json"));
299    }
300    let Some(mut harness) = harness_for(file, runner) else {
301        return paths;
302    };
303    if let Some(skills) = skills {
304        harness.skills = Some(skills.display().to_string());
305    }
306    if let Some(config) = harness.config.as_deref() {
307        paths.insert(crate::expand(config));
308    }
309    if let Some(config) = harness.json_config() {
310        paths.insert(crate::expand(config));
311    }
312    if runner == "claude" {
313        paths.insert(crate::expand("~/.claude.json"));
314    }
315    if let Some(hooks) = harness.hooks.as_deref() {
316        let hooks = crate::expand(hooks);
317        if harness.hooks_format.as_deref() == Some("cline") {
318            paths.insert(hooks.join("PreToolUse"));
319            paths.insert(hooks.join("UserPromptSubmit"));
320        } else {
321            paths.insert(hooks.clone());
322        }
323        if let Some(format) = harness.hooks_format.as_deref() {
324            if matches!(format, "auggie" | "amp") {
325                paths.insert(crate::runner_hooks::script_path(format, &hooks));
326            }
327            if format == "goose" {
328                if let Some(root) = hooks.parent().and_then(Path::parent) {
329                    paths.insert(root.join("plugin.json"));
330                }
331            }
332        }
333    }
334    if let Some(plugin) = harness.plugin.as_deref() {
335        paths.insert(crate::expand(plugin));
336    }
337    if let Some(skills) = harness.skills.as_deref() {
338        let skill = crate::expand(skills).join("ljos/SKILL.md");
339        paths.insert(skill.clone());
340        paths.insert(skill.with_file_name(".ljos-skill-sha256"));
341    }
342    if let Some(agents) = harness.agents.as_deref() {
343        let agents = crate::expand(agents);
344        add_tree_files(&mut paths, &agents, |path| {
345            path.file_name()
346                .and_then(|name| name.to_str())
347                .is_some_and(|name| name.starts_with("ljos-") && name.ends_with(".md"))
348        });
349    }
350    if harness.shell {
351        let env = crate::env_path_for(file, &harness);
352        paths.insert(env.clone());
353        let name = env
354            .file_name()
355            .and_then(|name| name.to_str())
356            .unwrap_or("env");
357        paths.insert(env.with_file_name(format!(".{name}.ljos-wrote")));
358        let guard = place
359            .map(|place| place.guard_dir.clone())
360            .unwrap_or_else(crate::guard_bin_dir);
361        for command in crate::GUARD_SHIMMED {
362            paths.insert(guard.join(command));
363        }
364    }
365    if runner == "grokbot" {
366        let hooks = place
367            .map(|place| place.hooks_dir.clone())
368            .unwrap_or_else(crate::git_hooks::onboard_hooks_dir);
369        paths.extend(crate::git_hooks::onboard_hook_paths(&hooks));
370        let git_env: Vec<(&str, &str)> = place
371            .map(|place| {
372                place
373                    .git_env
374                    .iter()
375                    .map(|(key, value)| (key.as_str(), value.as_str()))
376                    .collect()
377            })
378            .unwrap_or_default();
379        if let Some(configured) = crate::git_hooks::configured_hooks_path(&git_env) {
380            paths.extend(crate::git_hooks::onboard_hook_paths(&configured));
381        }
382        paths.insert(hooks.join("previous-hooks-path"));
383        let git_config = place
384            .and_then(|place| place.git_config.clone())
385            .or_else(|| std::env::var_os("GIT_CONFIG_GLOBAL").map(PathBuf::from))
386            .or_else(|| {
387                std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".gitconfig"))
388            });
389        if let Some(git_config) = git_config {
390            paths.insert(git_config);
391        }
392    }
393    paths
394}
395
396/// Undo onboarding for every runner, or only `runner`.
397///
398/// An unchanged file is restored byte for byte. A file with later edits is
399/// left in place unless the installed span is still present.
400pub fn offboard(runner: Option<&str>, dry: bool) -> Result<Vec<Step>> {
401    match read_manifest() {
402        Ok(mut manifest) => {
403            let mut steps = Vec::new();
404            let mut kept = Vec::new();
405            let mut marker_fallback = BTreeSet::new();
406            let mut selected = false;
407            for entry in manifest.entries.into_iter().rev() {
408                if runner.is_some_and(|name| name != entry.runner) {
409                    kept.push(entry);
410                    continue;
411                }
412                selected = true;
413                let (step, done) = reverse_entry(&entry, dry)?;
414                steps.push(step);
415                if !done || dry {
416                    if !done {
417                        marker_fallback.insert(entry.runner.clone());
418                    }
419                    kept.push(entry);
420                }
421            }
422            kept.reverse();
423            manifest.entries = kept;
424            for runner in &marker_fallback {
425                steps.extend(legacy_offboard(Some(runner), dry)?);
426            }
427            if !dry {
428                manifest
429                    .entries
430                    .retain(|entry| !marker_fallback.contains(&entry.runner));
431                write_manifest(&manifest)?;
432            }
433            if runner.is_some() && !selected {
434                steps.extend(legacy_offboard(runner, dry)?);
435            }
436            if steps.is_empty() {
437                steps.push(Step {
438                    what: "offboard".into(),
439                    detail: "nothing installed by ljos onboard".into(),
440                    ok: true,
441                });
442            }
443            Ok(steps)
444        }
445        Err(err) if missing_manifest(&err) => legacy_offboard(runner, dry),
446        Err(err) => Err(err),
447    }
448}
449
450fn missing_manifest(error: &anyhow::Error) -> bool {
451    error
452        .downcast_ref::<std::io::Error>()
453        .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
454}
455
456fn reverse_entry(entry: &Entry, dry: bool) -> Result<(Step, bool)> {
457    let before = entry.before.as_deref().map(load_backup).transpose()?;
458    let after = entry.after.as_deref().map(load_backup).transpose()?;
459    let current = read_state(&entry.path);
460    let exact = match (current.as_ref(), after.as_deref()) {
461        (None, None) => true,
462        (Some(current), Some(after)) => current.bytes == after && current.mode == entry.after_mode,
463        _ => false,
464    };
465    let action = if exact {
466        Some((before.clone(), entry.before_mode))
467    } else if current.is_none() && before.is_none() {
468        return Ok((
469            Step {
470                what: entry.runner.clone(),
471                detail: format!("{} is already absent", entry.path.display()),
472                ok: true,
473            },
474            true,
475        ));
476    } else {
477        current
478            .as_ref()
479            .map(|state| state.bytes.as_slice())
480            .zip(after.as_deref())
481            .and_then(|(current, after)| {
482                reverse_changed(before.as_deref().unwrap_or_default(), after, current)
483            })
484            .map(|bytes| (Some(bytes), current.as_ref().and_then(|state| state.mode)))
485    };
486    let Some(action) = action else {
487        return Ok((
488            Step {
489                what: entry.runner.clone(),
490                detail: format!(
491                    "left {}; it was edited after onboarding",
492                    entry.path.display()
493                ),
494                ok: true,
495            },
496            false,
497        ));
498    };
499    let verb = if action.0.is_some() {
500        "restore"
501    } else {
502        "remove"
503    };
504    if dry {
505        return Ok((
506            Step {
507                what: entry.runner.clone(),
508                detail: format!("would {verb} {}", entry.path.display()),
509                ok: true,
510            },
511            true,
512        ));
513    }
514    match action.0 {
515        Some(bytes) => {
516            if let Some(parent) = entry.path.parent() {
517                fs::create_dir_all(parent)?;
518            }
519            fs::write(&entry.path, bytes)?;
520            set_mode(&entry.path, action.1)?;
521        }
522        None => {
523            let _ = fs::remove_file(&entry.path);
524            remove_empty_parents(entry.path.parent(), home_path().as_deref());
525        }
526    }
527    Ok((
528        Step {
529            what: entry.runner.clone(),
530            detail: format!("{verb}d {}", entry.path.display()),
531            ok: true,
532        },
533        true,
534    ))
535}
536
537fn reverse_changed(before: &[u8], after: &[u8], current: &[u8]) -> Option<Vec<u8>> {
538    let mut prefix = 0;
539    let common = before.len().min(after.len());
540    while prefix < common && before[prefix] == after[prefix] {
541        prefix += 1;
542    }
543    let mut suffix = 0;
544    while suffix < before.len().saturating_sub(prefix)
545        && suffix < after.len().saturating_sub(prefix)
546        && before[before.len() - 1 - suffix] == after[after.len() - 1 - suffix]
547    {
548        suffix += 1;
549    }
550    let old = &before[prefix..before.len() - suffix];
551    let installed = &after[prefix..after.len() - suffix];
552    if installed.is_empty() {
553        return None;
554    }
555    let positions: Vec<usize> = current
556        .windows(installed.len())
557        .enumerate()
558        .filter_map(|(at, bytes)| (bytes == installed).then_some(at))
559        .collect();
560    if positions.len() != 1 {
561        return None;
562    }
563    let at = positions[0];
564    let mut out = Vec::with_capacity(current.len() + old.len() - installed.len());
565    out.extend_from_slice(&current[..at]);
566    out.extend_from_slice(old);
567    out.extend_from_slice(&current[at + installed.len()..]);
568    Some(out)
569}
570
571fn legacy_offboard(runner: Option<&str>, dry: bool) -> Result<Vec<Step>> {
572    let file = crate::harnesses_path();
573    let all = crate::harnesses_with_shipped(&file)?;
574    let shipped = toml::from_str::<Harnesses>(crate::HARNESSES_EXAMPLE).unwrap_or_default();
575    let names: BTreeSet<String> = all
576        .harness
577        .iter()
578        .chain(shipped.harness.iter())
579        .map(|h| h.name.clone())
580        .chain(["aider".to_string()])
581        .filter(|name| !name.starts_with("runner-with-"))
582        .filter(|name| runner.is_none_or(|selected| selected == name))
583        .collect();
584    let mut steps = Vec::new();
585    for name in names {
586        if name == "aider" {
587            legacy_aider(dry, &mut steps)?;
588            continue;
589        }
590        if let Some(harness) = harness_for(&file, &name) {
591            legacy_harness(&harness, dry, &mut steps)?;
592            clean_shipped_shape(&file, &name, dry, &mut steps)?;
593        }
594    }
595    if steps.is_empty() {
596        steps.push(Step {
597            what: "offboard".into(),
598            detail: runner.map_or_else(
599                || "nothing installed by ljos onboard".to_string(),
600                |runner| format!("no ljos markers found for {runner}"),
601            ),
602            ok: true,
603        });
604    }
605    Ok(steps)
606}
607
608fn clean_shipped_shape(path: &Path, runner: &str, dry: bool, steps: &mut Vec<Step>) -> Result<()> {
609    let Ok(text) = fs::read_to_string(path) else {
610        return Ok(());
611    };
612    let marker = format!("# The shipped {runner} shape, added by ljos onboard.\n");
613    let Some(marker_at) = text.find(&marker) else {
614        return Ok(());
615    };
616    let start = text[..marker_at].rfind('\n').map_or(marker_at, |at| at);
617    let body = marker_at + marker.len();
618    let end = text[body..]
619        .find("\n[[harness]]")
620        .map_or(text.len(), |at| body + at + 1);
621    if dry {
622        steps.push(would_remove(runner, path));
623        return Ok(());
624    }
625    let mut out = String::new();
626    out.push_str(&text[..start]);
627    out.push_str(&text[end..]);
628    if out.trim().is_empty() {
629        fs::remove_file(path)?;
630        remove_empty_parents(path.parent(), home_path().as_deref());
631    } else {
632        fs::write(path, out)?;
633    }
634    steps.push(removed(runner, path));
635    Ok(())
636}
637
638fn legacy_harness(harness: &Harness, dry: bool, steps: &mut Vec<Step>) -> Result<()> {
639    if let (Some(config), Some(pointer)) = (harness.json_config(), harness.json_pointer.as_deref())
640    {
641        clean_json_pointer(&crate::expand(config), pointer, &harness.name, dry, steps)?;
642    }
643    if let Some(config) = harness.config.as_deref() {
644        clean_config_marker(
645            &crate::expand(config),
646            harness.marker.as_deref().unwrap_or(""),
647            &harness.name,
648            dry,
649            steps,
650        )?;
651    }
652    if let Some(agents) = harness.agents.as_deref() {
653        let agents = crate::expand(agents);
654        let files: Vec<PathBuf> = fs::read_dir(&agents)
655            .into_iter()
656            .flatten()
657            .flatten()
658            .map(|entry| entry.path())
659            .filter(|path| {
660                path.file_name()
661                    .and_then(|name| name.to_str())
662                    .is_some_and(|name| name.starts_with("ljos-") && name.ends_with(".md"))
663            })
664            .collect();
665        for path in files {
666            remove_owned(
667                &path,
668                |bytes| String::from_utf8_lossy(bytes).contains(crate::AGENT_MARK),
669                &harness.name,
670                dry,
671                steps,
672            )?;
673        }
674    }
675    if harness.name == "claude" {
676        clean_json_pointer(
677            &crate::expand("~/.claude.json"),
678            "/mcpServers/ljos",
679            &harness.name,
680            dry,
681            steps,
682        )?;
683    }
684    if let Some(hooks) = harness.hooks.as_deref() {
685        let hooks = crate::expand(hooks);
686        clean_hooks(&hooks, harness, dry, steps)?;
687        if let Some(format) = harness.hooks_format.as_deref() {
688            if matches!(format, "auggie" | "amp") {
689                remove_owned(
690                    &crate::runner_hooks::script_path(format, &hooks),
691                    |bytes| String::from_utf8_lossy(bytes).contains("Written by ljos onboard"),
692                    &harness.name,
693                    dry,
694                    steps,
695                )?;
696            }
697            if format == "goose" {
698                if let Some(root) = hooks.parent().and_then(Path::parent) {
699                    remove_owned(
700                        &root.join("plugin.json"),
701                        |bytes| {
702                            let text = String::from_utf8_lossy(bytes);
703                            text.contains("\"name\": \"ljos\"")
704                                && text.contains("written by ljos onboard")
705                        },
706                        &harness.name,
707                        dry,
708                        steps,
709                    )?;
710                }
711            }
712        }
713    }
714    if let Some(plugin) = harness.plugin.as_deref() {
715        remove_owned(
716            &crate::expand(plugin),
717            |bytes| String::from_utf8_lossy(bytes).contains("ljos hook"),
718            &harness.name,
719            dry,
720            steps,
721        )?;
722    }
723    if let Some(skills) = harness.skills.as_deref() {
724        let skill = crate::expand(skills).join("ljos/SKILL.md");
725        clean_skill(&skill, &harness.name, dry, steps)?;
726    }
727    if harness.name == "grok" {
728        remove_owned(
729            &crate::expand("~/.grok/hooks/ljos.json"),
730            |bytes| String::from_utf8_lossy(bytes).contains("ljos hook"),
731            &harness.name,
732            dry,
733            steps,
734        )?;
735    }
736    if harness.shell {
737        clean_env(
738            &crate::env_path_for(&crate::harnesses_path(), harness),
739            harness,
740            dry,
741            steps,
742        )?;
743        for command in crate::GUARD_SHIMMED {
744            remove_owned(
745                &crate::guard_bin_dir().join(command),
746                |bytes| bytes == crate::GUARD_SHIM.as_bytes(),
747                &harness.name,
748                dry,
749                steps,
750            )?;
751        }
752    }
753    if harness.name == "grokbot" && crate::git_hooks::onboarding_installed() {
754        if dry {
755            steps.push(Step {
756                what: harness.name.clone(),
757                detail: "would remove ljos git hooks".into(),
758                ok: true,
759            });
760        } else {
761            crate::git_hooks::uninstall()?;
762            let git_config = std::env::var_os("GIT_CONFIG_GLOBAL")
763                .map(PathBuf::from)
764                .or_else(|| home_path().map(|home| home.join(".gitconfig")));
765            if let Some(path) = git_config {
766                if fs::read(&path).is_ok_and(|bytes| bytes.is_empty()) {
767                    let _ = fs::remove_file(path);
768                }
769            }
770            steps.push(Step {
771                what: harness.name.clone(),
772                detail: "removed ljos git hooks".into(),
773                ok: true,
774            });
775        }
776    }
777    Ok(())
778}
779
780fn clean_skill(path: &Path, runner: &str, dry: bool, steps: &mut Vec<Step>) -> Result<()> {
781    let sidecar = path.with_file_name(".ljos-skill-sha256");
782    let Ok(bytes) = fs::read(path) else {
783        return Ok(());
784    };
785    let text = String::from_utf8_lossy(&bytes);
786    if !text.starts_with("---\nname: ljos\n") || !text.contains("# The seat protocol") {
787        return Ok(());
788    }
789    if let Ok(recorded) = fs::read_to_string(&sidecar) {
790        let current = hex::encode(Sha256::digest(&bytes));
791        if recorded.trim() != current {
792            steps.push(Step {
793                what: runner.to_string(),
794                detail: format!("left {}; it was edited after onboarding", path.display()),
795                ok: true,
796            });
797            return Ok(());
798        }
799    }
800    if dry {
801        steps.push(would_remove(runner, path));
802        return Ok(());
803    }
804    fs::remove_file(path)?;
805    let _ = fs::remove_file(sidecar);
806    remove_empty_parents(path.parent(), home_path().as_deref());
807    steps.push(removed(runner, path));
808    Ok(())
809}
810
811fn clean_json_pointer(
812    path: &Path,
813    pointer: &str,
814    runner: &str,
815    dry: bool,
816    steps: &mut Vec<Step>,
817) -> Result<()> {
818    let Ok(text) = fs::read_to_string(path) else {
819        return Ok(());
820    };
821    let Ok(mut root) = serde_json::from_str::<Value>(&text) else {
822        return Ok(());
823    };
824    if root.pointer(pointer).is_none() {
825        return Ok(());
826    }
827    if dry {
828        steps.push(would_remove(runner, path));
829        return Ok(());
830    }
831    if remove_json_pointer(&mut root, pointer) {
832        prune_empty(&mut root);
833        if root.as_object().is_some_and(serde_json::Map::is_empty) {
834            fs::remove_file(path)?;
835            remove_empty_parents(path.parent(), home_path().as_deref());
836        } else {
837            let mut out = serde_json::to_string_pretty(&root)?;
838            out.push('\n');
839            fs::write(path, out)?;
840        }
841        steps.push(removed(runner, path));
842    }
843    Ok(())
844}
845
846fn remove_json_pointer(root: &mut Value, pointer: &str) -> bool {
847    let mut parts: Vec<&str> = pointer.trim_start_matches('/').split('/').collect();
848    let Some(last) = parts.pop() else {
849        return false;
850    };
851    let mut at = root;
852    for part in parts {
853        let Some(next) = at.get_mut(part) else {
854            return false;
855        };
856        at = next;
857    }
858    at.as_object_mut()
859        .and_then(|obj| obj.remove(last))
860        .is_some()
861}
862
863fn clean_config_marker(
864    path: &Path,
865    marker: &str,
866    runner: &str,
867    dry: bool,
868    steps: &mut Vec<Step>,
869) -> Result<()> {
870    if marker.is_empty() {
871        return Ok(());
872    }
873    let Ok(text) = fs::read_to_string(path) else {
874        return Ok(());
875    };
876    let Some(start) = text.find(marker.trim_start_matches('\n')) else {
877        return Ok(());
878    };
879    let line_start = text[..start].rfind('\n').map_or(0, |at| at + 1);
880    let rest = &text[start..];
881    let end = if marker.contains("mcp_servers") {
882        rest[1..]
883            .find("\n[")
884            .map_or(text.len(), |at| start + at + 1)
885    } else if marker.contains("  ljos:") {
886        rest.lines()
887            .skip(1)
888            .scan(
889                start + rest.lines().next().map_or(0, |line| line.len() + 1),
890                |at, line| {
891                    let here = *at;
892                    *at += line.len() + 1;
893                    Some((here, line))
894                },
895            )
896            .find(|(_, line)| !line.starts_with("    ") && !line.trim().is_empty())
897            .map_or(text.len(), |(at, _)| at)
898    } else {
899        return Ok(());
900    };
901    if dry {
902        steps.push(would_remove(runner, path));
903    } else {
904        let mut out = String::new();
905        out.push_str(&text[..line_start]);
906        out.push_str(&text[end..]);
907        if matches!(out.trim(), "" | "mcp_servers:" | "extensions:") {
908            fs::remove_file(path)?;
909            remove_empty_parents(path.parent(), home_path().as_deref());
910        } else {
911            fs::write(path, out)?;
912        }
913        steps.push(removed(runner, path));
914    }
915    Ok(())
916}
917
918fn clean_hooks(path: &Path, harness: &Harness, dry: bool, steps: &mut Vec<Step>) -> Result<()> {
919    if harness.hooks_format.as_deref() == Some("cline") {
920        for event in ["PreToolUse", "UserPromptSubmit"] {
921            remove_owned(
922                &path.join(event),
923                |bytes| String::from_utf8_lossy(bytes).contains("Written by ljos onboard"),
924                &harness.name,
925                dry,
926                steps,
927            )?;
928        }
929        return Ok(());
930    }
931    let Ok(text) = fs::read_to_string(path) else {
932        return Ok(());
933    };
934    let Ok(mut root) = serde_json::from_str::<Value>(&text) else {
935        return Ok(());
936    };
937    let before = root.clone();
938    strip_hook_values(&mut root);
939    if root == before {
940        return Ok(());
941    }
942    prune_empty(&mut root);
943    if dry {
944        steps.push(would_remove(&harness.name, path));
945    } else if only_hook_scaffold(&root) {
946        fs::remove_file(path)?;
947        remove_empty_parents(path.parent(), home_path().as_deref());
948        steps.push(removed(&harness.name, path));
949    } else {
950        let mut out = serde_json::to_string_pretty(&root)?;
951        out.push('\n');
952        fs::write(path, out)?;
953        steps.push(removed(&harness.name, path));
954    }
955    Ok(())
956}
957
958fn prune_empty(value: &mut Value) {
959    match value {
960        Value::Array(values) => {
961            for value in values.iter_mut() {
962                prune_empty(value);
963            }
964            values.retain(|value| !empty_json(value));
965        }
966        Value::Object(values) => {
967            for value in values.values_mut() {
968                prune_empty(value);
969            }
970            values.retain(|_, value| !empty_json(value));
971        }
972        _ => {}
973    }
974}
975
976fn empty_json(value: &Value) -> bool {
977    value.as_array().is_some_and(Vec::is_empty)
978        || value.as_object().is_some_and(serde_json::Map::is_empty)
979}
980
981fn only_hook_scaffold(value: &Value) -> bool {
982    value
983        .as_object()
984        .is_some_and(|object| object.is_empty() || object.keys().all(|key| key == "version"))
985}
986
987fn strip_hook_values(value: &mut Value) {
988    match value {
989        Value::Array(values) => {
990            for value in values.iter_mut() {
991                let had_hook = contains_hook_command(value);
992                strip_hook_values(value);
993                if had_hook && hook_container_empty(value) {
994                    *value = Value::Null;
995                }
996            }
997            values.retain(|item| !item.is_null() && !owned_hook_item(item));
998        }
999        Value::Object(values) => {
1000            values.retain(|key, value| key != "ljos" || !contains_hook_command(value));
1001            for value in values.values_mut() {
1002                strip_hook_values(value);
1003            }
1004        }
1005        _ => {}
1006    }
1007}
1008
1009fn hook_container_empty(value: &Value) -> bool {
1010    value.as_object().is_some_and(|object| {
1011        object.get("hooks").is_some_and(|hooks| {
1012            hooks.as_array().is_some_and(Vec::is_empty)
1013                || hooks.as_object().is_some_and(serde_json::Map::is_empty)
1014        })
1015    })
1016}
1017
1018fn owned_hook_item(value: &Value) -> bool {
1019    let Some(object) = value.as_object() else {
1020        return false;
1021    };
1022    let has_nested_list = object.values().any(Value::is_array);
1023    !has_nested_list && contains_hook_command(value)
1024}
1025
1026fn contains_hook_command(value: &Value) -> bool {
1027    match value {
1028        Value::String(text) => text.contains("ljos hook") || text.contains("ljos-guard-"),
1029        Value::Array(values) => values.iter().any(contains_hook_command),
1030        Value::Object(values) => values.values().any(contains_hook_command),
1031        _ => false,
1032    }
1033}
1034
1035fn clean_env(path: &Path, harness: &Harness, dry: bool, steps: &mut Vec<Step>) -> Result<()> {
1036    let name = path
1037        .file_name()
1038        .and_then(|name| name.to_str())
1039        .unwrap_or("env");
1040    let mark = path.with_file_name(format!(".{name}.ljos-wrote"));
1041    let Ok(template) = fs::read_to_string(&mark) else {
1042        return remove_owned(
1043            path,
1044            |bytes| String::from_utf8_lossy(bytes).contains("ljos/guard-bin"),
1045            &harness.name,
1046            dry,
1047            steps,
1048        );
1049    };
1050    let current = fs::read_to_string(path).unwrap_or_default();
1051    let mut kept: Vec<&str> = current.lines().collect();
1052    for line in template.lines() {
1053        if let Some(at) = kept.iter().position(|candidate| *candidate == line) {
1054            kept.remove(at);
1055        }
1056    }
1057    if dry {
1058        steps.push(would_remove(&harness.name, path));
1059        return Ok(());
1060    }
1061    if kept.iter().all(|line| line.trim().is_empty()) {
1062        let _ = fs::remove_file(path);
1063    } else {
1064        fs::write(path, kept.join("\n") + "\n")?;
1065    }
1066    let _ = fs::remove_file(mark);
1067    steps.push(removed(&harness.name, path));
1068    Ok(())
1069}
1070
1071fn legacy_aider(dry: bool, steps: &mut Vec<Step>) -> Result<()> {
1072    let home = home_path().unwrap_or_else(|| PathBuf::from("."));
1073    let convention = home.join(".aider/ljos-conventions.md");
1074    let entry = convention.display().to_string();
1075    remove_owned(
1076        &convention,
1077        |bytes| String::from_utf8_lossy(bytes).starts_with("---\nname: ljos\n"),
1078        "aider",
1079        dry,
1080        steps,
1081    )?;
1082    let config = home.join(".aider.conf.yml");
1083    let Ok(text) = fs::read_to_string(&config) else {
1084        return Ok(());
1085    };
1086    let out = aider_without_entry(&text, &entry);
1087    if out != text {
1088        if dry {
1089            steps.push(would_remove("aider", &config));
1090        } else if out.trim().is_empty() {
1091            fs::remove_file(&config)?;
1092            remove_empty_parents(config.parent(), home_path().as_deref());
1093            steps.push(removed("aider", &config));
1094        } else {
1095            fs::write(&config, out)?;
1096            steps.push(removed("aider", &config));
1097        }
1098    }
1099    Ok(())
1100}
1101
1102fn aider_without_entry(text: &str, entry: &str) -> String {
1103    let mut lines: Vec<&str> = text
1104        .lines()
1105        .filter(|line| {
1106            line.trim()
1107                .trim_start_matches("- ")
1108                .trim_matches(['"', '\''])
1109                != entry
1110        })
1111        .collect();
1112    let empty_read: Vec<usize> = lines
1113        .iter()
1114        .enumerate()
1115        .filter_map(|(at, line)| {
1116            if line.trim() != "read:" {
1117                return None;
1118            }
1119            let has_item = lines[at + 1..]
1120                .iter()
1121                .take_while(|next| next.trim().is_empty() || next.starts_with(char::is_whitespace))
1122                .any(|next| next.trim_start().starts_with("- "));
1123            (!has_item).then_some(at)
1124        })
1125        .collect();
1126    for at in empty_read.into_iter().rev() {
1127        lines.remove(at);
1128    }
1129    if lines.is_empty() {
1130        String::new()
1131    } else {
1132        lines.join("\n") + "\n"
1133    }
1134}
1135
1136fn remove_owned(
1137    path: &Path,
1138    owns: impl Fn(&[u8]) -> bool,
1139    runner: &str,
1140    dry: bool,
1141    steps: &mut Vec<Step>,
1142) -> Result<()> {
1143    let Ok(bytes) = fs::read(path) else {
1144        return Ok(());
1145    };
1146    if !owns(&bytes) {
1147        return Ok(());
1148    }
1149    if dry {
1150        steps.push(would_remove(runner, path));
1151    } else {
1152        fs::remove_file(path)?;
1153        remove_empty_parents(path.parent(), home_path().as_deref());
1154        steps.push(removed(runner, path));
1155    }
1156    Ok(())
1157}
1158
1159fn would_remove(runner: &str, path: &Path) -> Step {
1160    Step {
1161        what: runner.to_string(),
1162        detail: format!("would remove ljos from {}", path.display()),
1163        ok: true,
1164    }
1165}
1166
1167fn removed(runner: &str, path: &Path) -> Step {
1168    Step {
1169        what: runner.to_string(),
1170        detail: format!("removed ljos from {}", path.display()),
1171        ok: true,
1172    }
1173}
1174
1175fn home_path() -> Option<PathBuf> {
1176    std::env::var_os("HOME")
1177        .filter(|value| !value.is_empty())
1178        .map(PathBuf::from)
1179}
1180
1181fn remove_empty_parents(mut parent: Option<&Path>, stop: Option<&Path>) {
1182    while let Some(path) = parent {
1183        if stop.is_some_and(|stop| path == stop) || fs::remove_dir(path).is_err() {
1184            break;
1185        }
1186        parent = path.parent();
1187    }
1188}
1189
1190#[cfg(test)]
1191mod tests {
1192    use super::*;
1193
1194    #[test]
1195    fn a_later_edit_survives_the_reverse_span() {
1196        let before = b"one\ntwo\n";
1197        let after = b"one\nljos\ntwo\n";
1198        let current = b"mine\none\nljos\ntwo\n";
1199        assert_eq!(
1200            reverse_changed(before, after, current).as_deref(),
1201            Some(&b"mine\none\ntwo\n"[..])
1202        );
1203    }
1204
1205    #[test]
1206    fn removing_a_json_pointer_keeps_the_other_entries() {
1207        let mut value: Value =
1208            serde_json::from_str(r#"{"mcpServers":{"mine":{},"ljos":{"command":"ljos-mcp"}}}"#)
1209                .unwrap();
1210        assert!(remove_json_pointer(&mut value, "/mcpServers/ljos"));
1211        assert!(value.pointer("/mcpServers/mine").is_some());
1212        assert!(value.pointer("/mcpServers/ljos").is_none());
1213    }
1214}