1use 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
396pub 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(¤t[..at]);
566 out.extend_from_slice(old);
567 out.extend_from_slice(¤t[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}