Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ Backups are written under `~/Documents/CodexBackups` by default:
~/Documents/CodexBackups/codex-backup-YYYYMMDD-HHMMSS.tar.gz.sha256
```

If `~/Documents` already exists but is not a writable directory, the CLI falls
If `~/Documents` already exists but is not a directory, the CLI falls
back to `~/CodexBackups`.

For Claude Code, the default home is `~/.claude` and backups are written under
Expand Down
3 changes: 3 additions & 0 deletions skills/claude-code-environment-backup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,6 @@ Initial versions are manual-trigger only. If the user asks for periodic backups,
- Prefer exact paths from JSON output.
- Do not infer that restore succeeded from natural language alone; use the restore JSON, restored file count, errors list, and post-restore structural doctor report.
- For install/update/uninstall, report the executed instruction source and the final structural doctor/status result.


> Validation note: when you are testing across sandbox or permission boundaries, pass the same explicit `--backup-root` to `backup`, `list-backups`, and `restore` so discovery stays deterministic.
3 changes: 3 additions & 0 deletions skills/codex-environment-backup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,6 @@ Initial versions are manual-trigger only. If the user asks for periodic backups,
- Prefer exact paths from JSON output.
- Do not infer that restore succeeded from natural language alone; use the restore JSON, restored file count, errors list, and post-restore structural doctor report.
- For install/update/uninstall, report the executed instruction source and the final structural doctor/status result.


> Validation note: when you are testing across sandbox or permission boundaries, pass the same explicit `--backup-root` to `backup`, `list-backups`, and `restore` so discovery stays deterministic.
64 changes: 47 additions & 17 deletions src/agent_environment_backup/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,21 @@ def default_backup_root(profile: EnvironmentProfile | None = None) -> Path:
profile = CODEX_PROFILE
home = Path.home()
documents = home / "Documents"
if documents.exists() and (not documents.is_dir() or not os.access(documents, os.W_OK)):
if documents.exists() and not documents.is_dir():
return (home / profile.default_backup_subdir).resolve()
return (documents / profile.default_backup_subdir).resolve()



def default_backup_roots_for_discovery(profile: EnvironmentProfile | None = None) -> list[Path]:
if profile is None:
profile = CODEX_PROFILE
primary = default_backup_root(profile)
fallback = (Path.home() / profile.default_backup_subdir).resolve()
if fallback == primary:
return [primary]
return [primary, fallback]

def is_relative_to(child: Path, parent: Path) -> bool:
try:
child.resolve().relative_to(parent.resolve())
Expand Down Expand Up @@ -1933,21 +1943,41 @@ def list_backups(
) -> dict[str, Any]:
if profile is None:
profile = CODEX_PROFILE
root = Path(backup_root).expanduser().resolve() if backup_root else default_backup_root(profile)
roots = [Path(backup_root).expanduser().resolve()] if backup_root else default_backup_roots_for_discovery(profile)
root = roots[0]
items: list[dict[str, Any]] = []
if not root.exists():
return {"ok": True, "backup_root": str(root), "backups": items}
for manifest in sorted(root.rglob("manifest.json")):
try:
data = json.loads(manifest.read_text(encoding="utf-8"))
except Exception as exc:
items.append(
{
"backup_dir": str(manifest.parent),
"status": "unreadable",
"error": str(exc),
}
)
seen: set[Path] = set()
for candidate_root in roots:
if not candidate_root.exists():
continue
items.append(backup_list_item(manifest, data))
return {"ok": True, "backup_root": str(root), "backups": items}
for manifest in sorted(candidate_root.rglob("manifest.json")):
resolved_manifest = manifest.resolve()
if resolved_manifest in seen:
continue
seen.add(resolved_manifest)
try:
data = json.loads(manifest.read_text(encoding="utf-8"))
except Exception as exc:
items.append(
{
"backup_dir": str(manifest.parent),
"status": "unreadable",
"error": str(exc),
}
)
continue
items.append(backup_list_item(manifest, data))

def _sort_key(item: dict[str, Any]) -> tuple[str, str]:
created_at = item.get("created_at")
created_key = created_at if isinstance(created_at, str) else ("" if created_at is None else str(created_at))
backup_dir = item.get("backup_dir")
backup_dir_key = backup_dir if isinstance(backup_dir, str) else ("" if backup_dir is None else str(backup_dir))
return (created_key, backup_dir_key)

items.sort(key=_sort_key, reverse=True)

result: dict[str, Any] = {"ok": True, "backup_root": str(root), "backups": items}
if not backup_root and len(roots) > 1:
result["discovery_roots"] = [str(candidate) for candidate in roots]
return result
74 changes: 71 additions & 3 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -682,18 +682,86 @@ def test_default_backup_root_falls_back_when_documents_is_not_usable(self) -> No
self.assertEqual(codex_root, (fake_home / "CodexBackups").resolve())
self.assertEqual(claude_root, (fake_home / "ClaudeCodeBackups").resolve())

def test_default_backup_root_falls_back_when_documents_is_not_writable(self) -> None:
def test_default_backup_root_ignores_writability_checks(self) -> None:
from agent_environment_backup.core import default_backup_root, CODEX_PROFILE
with self.temp_root() as temp_dir:
fake_home = Path(temp_dir) / "home-with-unwritable-documents"
fake_home = Path(temp_dir) / "home-with-documents"
documents = fake_home / "Documents"
documents.mkdir(parents=True)
with (
mock.patch.object(core_module.Path, "home", return_value=fake_home),
mock.patch.object(core_module.os, "access", return_value=False),
):
result = default_backup_root(CODEX_PROFILE)
self.assertEqual(result, (fake_home / "CodexBackups").resolve())
self.assertEqual(result, (fake_home / "Documents" / "CodexBackups").resolve())


def test_list_backups_discovers_legacy_home_fallback_root(self) -> None:
from agent_environment_backup.core import list_backups, CODEX_PROFILE
with self.temp_root() as temp_dir:
fake_home = Path(temp_dir) / "home-for-discovery"
docs_root = fake_home / "Documents" / "CodexBackups"
fallback_root = fake_home / "CodexBackups"
docs_root.mkdir(parents=True)
backup_dir = fallback_root / "codex-backup-20260520-141125"
backup_dir.mkdir(parents=True)
(backup_dir / "manifest.json").write_text(
json.dumps(
{
"schema_version": 1,
"created_at": "2026-05-20T14:11:25Z",
"source_home": str(fake_home / ".codex"),
"backup_label": "codex-backup-20260520-141125",
"profile": "codex",
}
),
encoding="utf-8",
)
with mock.patch.object(core_module.Path, "home", return_value=fake_home):
listing = list_backups(profile=CODEX_PROFILE)
self.assertTrue(listing["ok"])
self.assertEqual(listing["backup_root"], str(docs_root.resolve()))
self.assertIn(str(fallback_root.resolve()), listing.get("discovery_roots", []))
self.assertEqual(len(listing["backups"]), 1)
self.assertTrue(listing["backups"][0]["backup_dir"].endswith("codex-backup-20260520-141125"))


def test_list_backups_default_discovery_is_sorted_by_created_at_desc(self) -> None:
from agent_environment_backup.core import list_backups, CODEX_PROFILE
with self.temp_root() as temp_dir:
fake_home = Path(temp_dir) / "home-for-sort"
docs_root = fake_home / "Documents" / "CodexBackups"
fallback_root = fake_home / "CodexBackups"
first = docs_root / "codex-backup-older"
second = fallback_root / "codex-backup-newer"
first.mkdir(parents=True)
second.mkdir(parents=True)
(first / "manifest.json").write_text(
json.dumps(
{
"schema_version": 1,
"created_at": "2026-05-20T14:00:00Z",
"source_home": str(fake_home / ".codex"),
"profile": "codex",
}
),
encoding="utf-8",
)
(second / "manifest.json").write_text(
json.dumps(
{
"schema_version": 1,
"created_at": "2026-05-20T14:11:25Z",
"source_home": str(fake_home / ".codex"),
"profile": "codex",
}
),
encoding="utf-8",
)
with mock.patch.object(core_module.Path, "home", return_value=fake_home):
listing = list_backups(profile=CODEX_PROFILE)
self.assertEqual(len(listing["backups"]), 2)
self.assertTrue(listing["backups"][0]["backup_dir"].endswith("codex-backup-newer"))

def test_inspect_claude_code_config(self) -> None:
from agent_environment_backup.core import inspect_claude_code_config
Expand Down
Loading