From 9682a229d2ffa00703a5d66566c023a27a3c1dd3 Mon Sep 17 00:00:00 2001 From: gaoguobin Date: Wed, 20 May 2026 11:27:41 +0800 Subject: [PATCH] fix: install via venv on Linux --- .claude/INSTALL.md | 17 ++++-- .claude/UNINSTALL.md | 24 -------- .claude/UPDATE.md | 17 +++++- .codex/INSTALL.md | 17 ++++-- .codex/UNINSTALL.md | 24 -------- .codex/UPDATE.md | 26 ++++++++- README.md | 58 ++++++++++++++++++- .../claude-code-environment-backup/SKILL.md | 34 ++++++----- skills/codex-environment-backup/SKILL.md | 34 ++++++----- src/agent_environment_backup/core.py | 12 +++- tests/test_core.py | 34 +++++++++++ tests/test_docs.py | 52 ++++++++++++++++- 12 files changed, 254 insertions(+), 95 deletions(-) diff --git a/.claude/INSTALL.md b/.claude/INSTALL.md index 205445c..03e2be2 100644 --- a/.claude/INSTALL.md +++ b/.claude/INSTALL.md @@ -13,7 +13,9 @@ Fetch and follow instructions from https://raw.githubusercontent.com/gaoguobin/c ## What this installs - Git repo: `~/.claude/agent-environment-backup` -- Python package: editable user install of `agent-environment-backup` +- Python package: + - Windows: editable user install + - macOS/Linux: repo-local virtual environment at `~/.claude/agent-environment-backup/.venv` - Skill link: `~/.claude/skills/claude-code-environment-backup -> ~/.claude/agent-environment-backup/skills/claude-code-environment-backup` The install does not create a backup, restore anything, change providers, write hooks, or upload data. @@ -85,6 +87,7 @@ Run this shell block exactly: set -euo pipefail repo_root="$HOME/.claude/agent-environment-backup" +venv_dir="$repo_root/.venv" skills_root="$HOME/.claude/skills" skill_namespace="$skills_root/claude-code-environment-backup" skill_source="$repo_root/skills/claude-code-environment-backup" @@ -114,16 +117,22 @@ if [ -e "$repo_root" ]; then exit 1 fi -if [ -e "$skill_namespace" ]; then +if [ -e "$skill_namespace" ] || [ -L "$skill_namespace" ]; then echo "The skill namespace link already exists. Remove it or follow UNINSTALL.md before reinstalling." >&2 exit 1 fi mkdir -p "$skills_root" git clone https://github.com/gaoguobin/codex-environment-backup.git "$repo_root" -"$python_cmd" -m pip install --user -e "$repo_root" +"$python_cmd" -m venv "$venv_dir" || { + echo "Failed to create a virtual environment. On Debian/Ubuntu, install python3-venv for your Python version, then rerun this INSTALL.md." >&2 + exit 1 +} +venv_python="$venv_dir/bin/python" +cli="$venv_dir/bin/agent-environment-backup" +"$venv_python" -m pip install -e "$repo_root" ln -s "$skill_source" "$skill_namespace" -"$python_cmd" -m agent_environment_backup --profile claude-code doctor +"$cli" --profile claude-code doctor ``` ## After install diff --git a/.claude/UNINSTALL.md b/.claude/UNINSTALL.md index aafa7cd..6ef0942 100644 --- a/.claude/UNINSTALL.md +++ b/.claude/UNINSTALL.md @@ -78,30 +78,6 @@ set -euo pipefail repo_root="$HOME/.claude/agent-environment-backup" skill_namespace="$HOME/.claude/skills/claude-code-environment-backup" -python_cmd="${PYTHON:-}" - -if [ -z "$python_cmd" ]; then - for candidate in python3 python; do - if command -v "$candidate" >/dev/null 2>&1 && "$candidate" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)' >/dev/null 2>&1; then - python_cmd="$candidate" - break - fi - done -fi - -if [ -z "$python_cmd" ]; then - for candidate in python3 python; do - if command -v "$candidate" >/dev/null 2>&1; then - python_cmd="$candidate" - break - fi - done -fi - -if [ -n "$python_cmd" ]; then - "$python_cmd" -m pip uninstall -y agent-environment-backup -fi - if [ -L "$skill_namespace" ] || [ -e "$skill_namespace" ]; then rm "$skill_namespace" fi diff --git a/.claude/UPDATE.md b/.claude/UPDATE.md index 8f708c7..0ea29ed 100644 --- a/.claude/UPDATE.md +++ b/.claude/UPDATE.md @@ -78,6 +78,7 @@ Run this shell block exactly: set -euo pipefail repo_root="$HOME/.claude/agent-environment-backup" +venv_dir="$repo_root/.venv" skill_namespace="$HOME/.claude/skills/claude-code-environment-backup" skill_source="$repo_root/skills/claude-code-environment-backup" python_cmd="${PYTHON:-}" @@ -106,7 +107,19 @@ if [ ! -d "$repo_root" ]; then fi git -C "$repo_root" pull --ff-only -"$python_cmd" -m pip install --user -e "$repo_root" +if [ ! -d "$venv_dir" ]; then + "$python_cmd" -m venv "$venv_dir" || { + echo "Failed to create a virtual environment. On Debian/Ubuntu, install python3-venv for your Python version, then rerun this UPDATE.md." >&2 + exit 1 + } +fi +venv_python="$venv_dir/bin/python" +cli="$venv_dir/bin/agent-environment-backup" +"$venv_python" -m pip install -e "$repo_root" + +if [ -L "$skill_namespace" ] && [ ! -e "$skill_namespace" ]; then + rm "$skill_namespace" +fi if [ ! -e "$skill_namespace" ]; then mkdir -p "$(dirname "$skill_namespace")" @@ -121,7 +134,7 @@ elif [ ! -f "$skill_namespace/SKILL.md" ]; then fi fi -"$python_cmd" -m agent_environment_backup --profile claude-code doctor +"$cli" --profile claude-code doctor ``` Report the final structural doctor JSON. If skill files changed or the skill link was newly created, explicitly tell the user: diff --git a/.codex/INSTALL.md b/.codex/INSTALL.md index 0f89cd0..70edef8 100644 --- a/.codex/INSTALL.md +++ b/.codex/INSTALL.md @@ -13,7 +13,9 @@ Fetch and follow instructions from https://raw.githubusercontent.com/gaoguobin/c ## What this installs - Git repo: `~/.codex/codex-environment-backup` -- Python package: editable user install of `codex-environment-backup` +- Python package: + - Windows: editable user install + - macOS/Linux: repo-local virtual environment at `~/.codex/codex-environment-backup/.venv` - Skill namespace link: `~/.agents/skills/codex-environment-backup -> ~/.codex/codex-environment-backup/skills` The install does not create a backup, restore anything, change providers, write hooks, or upload data. @@ -86,6 +88,7 @@ Run this shell block exactly: set -euo pipefail repo_root="$HOME/.codex/codex-environment-backup" +venv_dir="$repo_root/.venv" skills_root="$HOME/.agents/skills" skill_namespace="$skills_root/codex-environment-backup" @@ -114,16 +117,22 @@ if [ -e "$repo_root" ]; then exit 1 fi -if [ -e "$skill_namespace" ]; then +if [ -e "$skill_namespace" ] || [ -L "$skill_namespace" ]; then echo "The skill namespace link already exists. Remove it or follow UNINSTALL.md before reinstalling." >&2 exit 1 fi mkdir -p "$skills_root" git clone https://github.com/gaoguobin/codex-environment-backup.git "$repo_root" -"$python_cmd" -m pip install --user -e "$repo_root" +"$python_cmd" -m venv "$venv_dir" || { + echo "Failed to create a virtual environment. On Debian/Ubuntu, install python3-venv for your Python version, then rerun this INSTALL.md." >&2 + exit 1 +} +venv_python="$venv_dir/bin/python" +cli="$venv_dir/bin/agent-environment-backup" +"$venv_python" -m pip install -e "$repo_root" ln -s "$repo_root/skills" "$skill_namespace" -"$python_cmd" -m agent_environment_backup --profile codex doctor +"$cli" --profile codex doctor ``` ## After install diff --git a/.codex/UNINSTALL.md b/.codex/UNINSTALL.md index 364132a..4b1a378 100644 --- a/.codex/UNINSTALL.md +++ b/.codex/UNINSTALL.md @@ -80,30 +80,6 @@ set -euo pipefail repo_root="$HOME/.codex/codex-environment-backup" skill_namespace="$HOME/.agents/skills/codex-environment-backup" -python_cmd="${PYTHON:-}" - -if [ -z "$python_cmd" ]; then - for candidate in python3 python; do - if command -v "$candidate" >/dev/null 2>&1 && "$candidate" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)' >/dev/null 2>&1; then - python_cmd="$candidate" - break - fi - done -fi - -if [ -z "$python_cmd" ]; then - for candidate in python3 python; do - if command -v "$candidate" >/dev/null 2>&1; then - python_cmd="$candidate" - break - fi - done -fi - -if [ -n "$python_cmd" ]; then - "$python_cmd" -m pip uninstall -y agent-environment-backup codex-environment-backup -fi - if [ -L "$skill_namespace" ] || [ -e "$skill_namespace" ]; then rm "$skill_namespace" fi diff --git a/.codex/UPDATE.md b/.codex/UPDATE.md index 3fb5cb6..5732b29 100644 --- a/.codex/UPDATE.md +++ b/.codex/UPDATE.md @@ -72,6 +72,7 @@ Run this shell block exactly: set -euo pipefail repo_root="$HOME/.codex/codex-environment-backup" +venv_dir="$repo_root/.venv" skill_namespace="$HOME/.agents/skills/codex-environment-backup" python_cmd="${PYTHON:-}" @@ -99,15 +100,34 @@ if [ ! -d "$repo_root" ]; then fi git -C "$repo_root" pull --ff-only -"$python_cmd" -m pip uninstall -y codex-environment-backup -"$python_cmd" -m pip install --user -e "$repo_root" +if [ ! -d "$venv_dir" ]; then + "$python_cmd" -m venv "$venv_dir" || { + echo "Failed to create a virtual environment. On Debian/Ubuntu, install python3-venv for your Python version, then rerun this UPDATE.md." >&2 + exit 1 + } +fi +venv_python="$venv_dir/bin/python" +cli="$venv_dir/bin/agent-environment-backup" +"$venv_python" -m pip install -e "$repo_root" + +if [ -L "$skill_namespace" ] && [ ! -e "$skill_namespace" ]; then + rm "$skill_namespace" +fi if [ ! -e "$skill_namespace" ]; then mkdir -p "$(dirname "$skill_namespace")" ln -s "$repo_root/skills" "$skill_namespace" +elif [ ! -f "$skill_namespace/codex-environment-backup/SKILL.md" ]; then + if [ -L "$skill_namespace" ]; then + rm "$skill_namespace" + ln -s "$repo_root/skills" "$skill_namespace" + else + echo "Existing skill namespace is not a link and does not contain codex-environment-backup/SKILL.md: $skill_namespace" >&2 + exit 1 + fi fi -"$python_cmd" -m agent_environment_backup --profile codex doctor +"$cli" --profile codex doctor ``` Report the final structural doctor JSON. The default doctor intentionally skips external `codex` commands to avoid sandbox noise before restart. If skill files changed or the skill link was newly created, explicitly tell the user in the user's language: diff --git a/README.md b/README.md index db338a8..513c7d8 100644 --- a/README.md +++ b/README.md @@ -105,8 +105,10 @@ Fetch and follow instructions from https://raw.githubusercontent.com/gaoguobin/c ``` The install flow clones this repository to `~/.codex/codex-environment-backup`, -installs the Python package in editable user mode, and links the bundled skill -into `~/.agents/skills`. +installs the Python package, and links the bundled skill into `~/.agents/skills`. +On macOS and Linux, the install uses a repo-local virtual environment at +`~/.codex/codex-environment-backup/.venv` so it does not depend on system +`pip`, `pip --user`, or `--break-system-packages`. After installation, restart Codex App and return to the same conversation, or open a new Codex CLI process. Then ask: @@ -125,7 +127,8 @@ Fetch and follow instructions from https://raw.githubusercontent.com/gaoguobin/c The install flow clones this repository to `~/.claude/agent-environment-backup`, installs the same Python package, and links the Claude Code skill into -`~/.claude/skills`. +`~/.claude/skills`. On macOS and Linux, this also uses a repo-local virtual +environment at `~/.claude/agent-environment-backup/.venv`. After installation, restart Claude Code or open a new CLI process. Then ask: @@ -221,6 +224,9 @@ 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 +back to `~/CodexBackups`. + For Claude Code, the default home is `~/.claude` and backups are written under `~/Documents/ClaudeCodeBackups`: @@ -230,6 +236,9 @@ For Claude Code, the default home is `~/.claude` and backups are written under ~/Documents/ClaudeCodeBackups/claude-code-backup-YYYYMMDD-HHMMSS.tar.gz.sha256 ``` +If `~/Documents` already exists but is not a writable directory, the CLI falls +back to `~/ClaudeCodeBackups`. + Each backup directory contains: ```text @@ -272,6 +281,9 @@ only recovery path for lost Codex history or provider state. - SQLite databases ending in `.sqlite` are copied with the Python `sqlite3` backup API and then checked with `PRAGMA integrity_check`. - Windows uses junctions for skill installation; macOS and Linux use symlinks. +- macOS and Linux installs use a repo-local `.venv` to avoid Ubuntu/Debian + PEP 668 `externally-managed-environment` failures and systems without + `python3 -m pip`. `codex-fast-proxy` is optional. If `python -m codex_fast_proxy` is available in the current Python environment, `doctor` records safe `status` / `doctor` @@ -339,6 +351,18 @@ Codex cannot operate. Use Python 3.11 or newer. Prefer `python3` when it exists; on Windows, `python` is acceptable when it points to Python 3.11+. +After a Codex install on macOS/Linux, the natural-language skill uses: + +```text +~/.codex/codex-environment-backup/.venv/bin/agent-environment-backup +``` + +After a Claude Code install on macOS/Linux, it uses: + +```text +~/.claude/agent-environment-backup/.venv/bin/agent-environment-backup +``` + ```powershell # Codex (default profile) python -m agent_environment_backup doctor @@ -408,6 +432,8 @@ Fetch and follow instructions from https://raw.githubusercontent.com/gaoguobin/c ``` 安装只会安装仓库、Python 包和 skill 链接;不会创建备份、恢复文件、修改 provider、写 hooks 或上传数据。 +在 macOS/Linux 上,安装使用仓库内 `.venv`,不依赖系统 `python3 -m pip`、`pip --user` 或 +`--break-system-packages`。 Claude Code: @@ -423,6 +449,8 @@ Fetch and follow instructions from https://raw.githubusercontent.com/gaoguobin/c 备份当前 Claude Code 环境 ``` +在 macOS/Linux 上,Claude Code 安装同样使用 `~/.claude/agent-environment-backup/.venv`。 + ### 日常用法 对 Codex 说: @@ -497,12 +525,24 @@ Windows 上通常类似: C:\Users\\Documents\CodexBackups ``` +如果 `~/Documents` 已存在但不是可写目录,会回退到: + +```text +~/CodexBackups +``` + Claude Code 默认读取 `~/.claude`,默认备份位置是: ```text ~/Documents/ClaudeCodeBackups ``` +如果 `~/Documents` 已存在但不是可写目录,会回退到: + +```text +~/ClaudeCodeBackups +``` + ### 备份保留 工具不会默认删除旧备份。正式安装后,建议先完成一次新的正式备份,并确认: @@ -573,6 +613,18 @@ SkillsMP 或其它 marketplace 收录,也不声称是 OpenAI 官方 plugin 或 CLI 留给高级用户、CI、smoke test、自动化和 Codex 自身不可用时的恢复兜底。普通备份、检查和列出备份 不应要求用户手动运行命令。 +macOS/Linux 正式安装后,Codex skill 优先使用: + +```text +~/.codex/codex-environment-backup/.venv/bin/agent-environment-backup +``` + +Claude Code skill 优先使用: + +```text +~/.claude/agent-environment-backup/.venv/bin/agent-environment-backup +``` + ```powershell # Codex(默认 profile) python -m agent_environment_backup doctor diff --git a/skills/claude-code-environment-backup/SKILL.md b/skills/claude-code-environment-backup/SKILL.md index eafd25f..c43e52d 100644 --- a/skills/claude-code-environment-backup/SKILL.md +++ b/skills/claude-code-environment-backup/SKILL.md @@ -16,17 +16,23 @@ Use this skill when the user wants Claude Code to manage local Claude Code envir ## How to execute -Resolve the Python command first. Prefer `python3`; fall back to `python` only when it is Python 3.11 or newer. Do not use the Windows `py` launcher as the default command. +Resolve `` first. Prefer the installed repo-local virtualenv command when it exists: + +- macOS/Linux install path: `~/.claude/agent-environment-backup/.venv/bin/agent-environment-backup` +- Windows fallback: resolve `python3`, then `python`, and run ` -m agent_environment_backup` +- Source checkout fallback: resolve `python3`, then `python`, and run ` -m agent_environment_backup` + +Use Python 3.11 or newer for Python fallbacks. Do not use the Windows `py` launcher as the default command. Run the CLI as the source of truth: ```text - -m agent_environment_backup --profile claude-code doctor - -m agent_environment_backup --profile claude-code doctor --run-commands - -m agent_environment_backup --profile claude-code backup - -m agent_environment_backup --profile claude-code list-backups - -m agent_environment_backup --profile claude-code restore --archive - -m agent_environment_backup --profile claude-code restore --archive --apply --i-understand-this-restores-sensitive-state + --profile claude-code doctor + --profile claude-code doctor --run-commands + --profile claude-code backup + --profile claude-code list-backups + --profile claude-code restore --archive + --profile claude-code restore --archive --apply --i-understand-this-restores-sensitive-state ``` Resolve the Claude Code home directory in this order: @@ -40,8 +46,8 @@ Use `--backup-root ` when the user names a backup destination. Otherwise u For natural language backup requests: -1. Run ` -m agent_environment_backup --profile claude-code doctor`. -2. Run ` -m agent_environment_backup --profile claude-code backup`. +1. Run ` --profile claude-code doctor`. +2. Run ` --profile claude-code backup`. 3. Report `ok`, `backup_dir`, `archive`, `archive_sha256`, `sha256_file`, and `counts`. 4. Remind the user that the archive is local and sensitive. @@ -54,7 +60,7 @@ Do not ask the user to run commands for normal backup requests. Ask for approval For restore requests: 1. Ask for or locate the backup archive/directory. -2. Run dry-run first with ` -m agent_environment_backup --profile claude-code restore --archive `. +2. Run dry-run first with ` --profile claude-code restore --archive `. 3. Report the restore plan and whether the target appears to be the active Claude Code home directory (`~/.claude`). 4. If the user only wanted a plan, stop after dry-run. 5. Apply only after explicit confirmation. @@ -72,7 +78,7 @@ Provide the exact CLI handoff only for advanced users or when the restore helper When apply is safe to run from the current context, run: ```text - -m agent_environment_backup --profile claude-code restore --archive --apply --i-understand-this-restores-sensitive-state + --profile claude-code restore --archive --apply --i-understand-this-restores-sensitive-state ``` The CLI creates a pre-restore backup before applying. Restore overlays backed-up files and does not prune excluded paths. @@ -83,7 +89,7 @@ The default post-restore doctor is structural only. Do not add `--run-post-resto For health checks: ```text - -m agent_environment_backup --profile claude-code doctor + --profile claude-code doctor ``` This is the default path for natural-language health checks. It reports backup readiness without external command probe noise. @@ -91,13 +97,13 @@ This is the default path for natural-language health checks. It reports backup r For explicit command-level checks only: ```text - -m agent_environment_backup --profile claude-code doctor --run-commands + --profile claude-code doctor --run-commands ``` For listing backups: ```text - -m agent_environment_backup --profile claude-code list-backups + --profile claude-code list-backups ``` Report `core_ok`, `path_scan_ok`, `command_ok`, whether command probes were skipped, and presence/counts for `settings.json`, `settings.local.json`, `credentials.json`, `statsig`, `projects`, `memory`, `todos`, `plugins`, and `keybindings.json`. Do not print provider URLs, local state paths, or full integration stdout from optional integrations. diff --git a/skills/codex-environment-backup/SKILL.md b/skills/codex-environment-backup/SKILL.md index 94f011c..7acea03 100644 --- a/skills/codex-environment-backup/SKILL.md +++ b/skills/codex-environment-backup/SKILL.md @@ -16,17 +16,23 @@ Use this skill when the user wants Codex to manage local Codex environment backu ## How to execute -Resolve the Python command first. Prefer `python3`; fall back to `python` only when it is Python 3.11 or newer. Do not use the Windows `py` launcher as the default command. +Resolve `` first. Prefer the installed repo-local virtualenv command when it exists: + +- macOS/Linux install path: `~/.codex/codex-environment-backup/.venv/bin/agent-environment-backup` +- Windows fallback: resolve `python3`, then `python`, and run ` -m agent_environment_backup` +- Source checkout fallback: resolve `python3`, then `python`, and run ` -m agent_environment_backup` + +Use Python 3.11 or newer for Python fallbacks. Do not use the Windows `py` launcher as the default command. Run the CLI as the source of truth: ```text - -m agent_environment_backup --profile codex doctor - -m agent_environment_backup --profile codex doctor --run-commands - -m agent_environment_backup --profile codex backup - -m agent_environment_backup --profile codex list-backups - -m agent_environment_backup --profile codex restore --archive - -m agent_environment_backup --profile codex restore --archive --apply --i-understand-this-restores-sensitive-codex-state + --profile codex doctor + --profile codex doctor --run-commands + --profile codex backup + --profile codex list-backups + --profile codex restore --archive + --profile codex restore --archive --apply --i-understand-this-restores-sensitive-codex-state ``` Resolve `CODEX_HOME` in this order: @@ -41,8 +47,8 @@ Use `--backup-root ` when the user names a backup destination. Otherwise u For natural language backup requests: -1. Run ` -m agent_environment_backup --profile codex doctor`. -2. Run ` -m agent_environment_backup --profile codex backup`. +1. Run ` --profile codex doctor`. +2. Run ` --profile codex backup`. 3. Report `ok`, `backup_dir`, `archive`, `archive_sha256`, `sha256_file`, and `counts`. 4. Remind the user that the archive is local and sensitive. @@ -55,7 +61,7 @@ Do not ask the user to run commands for normal backup requests. Ask for approval For restore requests: 1. Ask for or locate the backup archive/directory. -2. Run dry-run first with ` -m agent_environment_backup --profile codex restore --archive `. +2. Run dry-run first with ` --profile codex restore --archive `. 3. Report the restore plan and whether the target appears to be the active `CODEX_HOME`. 4. If the user only wanted a plan, stop after dry-run. 5. Apply only after explicit confirmation. @@ -73,7 +79,7 @@ Provide the exact CLI handoff only for advanced users or when the restore helper When apply is safe to run from the current context, run: ```text - -m agent_environment_backup --profile codex restore --archive --apply --i-understand-this-restores-sensitive-codex-state + --profile codex restore --archive --apply --i-understand-this-restores-sensitive-codex-state ``` The CLI creates a pre-restore backup before applying. Restore overlays backed-up files and does not prune excluded paths. @@ -84,7 +90,7 @@ The default post-restore doctor is structural only. Do not add `--run-post-resto For health checks: ```text - -m agent_environment_backup --profile codex doctor + --profile codex doctor ``` This is the default path for natural-language health checks. It reports backup readiness without external command probe noise. @@ -92,13 +98,13 @@ This is the default path for natural-language health checks. It reports backup r For explicit command-level checks only: ```text - -m agent_environment_backup --profile codex doctor --run-commands + --profile codex doctor --run-commands ``` For listing backups: ```text - -m agent_environment_backup --profile codex list-backups + --profile codex list-backups ``` Report `core_ok`, `path_scan_ok`, `command_ok`, whether command probes were skipped, and presence/counts for config, hooks, sessions, archived sessions, memories, skills, plugins, rules, automations, and optional `codex-fast-proxy` status. If `codex_fast_proxy` is not installed, report it as skipped rather than failed. Do not print provider URLs, local state paths, or full integration stdout from optional integrations. diff --git a/src/agent_environment_backup/core.py b/src/agent_environment_backup/core.py index 22e390f..9638c34 100644 --- a/src/agent_environment_backup/core.py +++ b/src/agent_environment_backup/core.py @@ -101,7 +101,11 @@ def resolve_codex_home(codex_home: str | os.PathLike[str] | None = None) -> Path def default_backup_root(profile: EnvironmentProfile | None = None) -> Path: if profile is None: profile = CODEX_PROFILE - return (Path.home() / "Documents" / profile.default_backup_subdir).resolve() + home = Path.home() + documents = home / "Documents" + if documents.exists() and (not documents.is_dir() or not os.access(documents, os.W_OK)): + return (home / profile.default_backup_subdir).resolve() + return (documents / profile.default_backup_subdir).resolve() def is_relative_to(child: Path, parent: Path) -> bool: @@ -797,7 +801,11 @@ def resolve_target_home(target_home: str | None = None, profile: str | None = No def default_backup_root(profile: str | None = None) -> Path: subdir = "ClaudeCodeBackups" if profile == "claude-code" else "CodexBackups" - return (Path.home() / "Documents" / subdir).resolve() + home = Path.home() + documents = home / "Documents" + if documents.exists() and (not documents.is_dir() or not os.access(documents, os.W_OK)): + return (home / subdir).resolve() + return (documents / subdir).resolve() def is_excluded(relative_path: Path, profile: str = "codex") -> bool: excluded = EXCLUDED_DIR_NAMES | PROFILE_EXTRA_EXCLUDED.get(profile, set()) diff --git a/tests/test_core.py b/tests/test_core.py index 0ed57bb..a57f6cd 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -556,6 +556,40 @@ def test_default_backup_root_uses_profile(self) -> None: self.assertTrue(str(codex_root).endswith("CodexBackups")) self.assertTrue(str(claude_root).endswith("ClaudeCodeBackups")) + def test_default_backup_root_allows_missing_documents_under_temp_home(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-without-documents" + fake_home.mkdir() + with mock.patch.object(core_module.Path, "home", return_value=fake_home): + result = default_backup_root(CODEX_PROFILE) + self.assertEqual(result, (fake_home / "Documents" / "CodexBackups").resolve()) + + def test_default_backup_root_falls_back_when_documents_is_not_usable(self) -> None: + from agent_environment_backup.core import default_backup_root, CODEX_PROFILE, CLAUDE_CODE_PROFILE + with self.temp_root() as temp_dir: + fake_home = Path(temp_dir) / "home-with-file-documents" + fake_home.mkdir() + (fake_home / "Documents").write_text("not a directory", encoding="utf-8") + with mock.patch.object(core_module.Path, "home", return_value=fake_home): + codex_root = default_backup_root(CODEX_PROFILE) + claude_root = default_backup_root(CLAUDE_CODE_PROFILE) + 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: + 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" + 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()) + def test_inspect_claude_code_config(self) -> None: from agent_environment_backup.core import inspect_claude_code_config with self.temp_root() as temp_dir: diff --git a/tests/test_docs.py b/tests/test_docs.py index 1257850..1a6f3f6 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -67,6 +67,52 @@ def test_codex_lifecycle_docs_keep_chinese_handoff(self) -> None: self.assertIn("让它从 skill 列表中移除 codex-environment-backup", uninstall) self.assertIn("agent-environment-backup codex-environment-backup", uninstall) + def test_posix_install_update_use_repo_local_venv(self) -> None: + for relative_path in ( + ".codex/INSTALL.md", + ".codex/UPDATE.md", + ".claude/INSTALL.md", + ".claude/UPDATE.md", + ): + content = self.read(relative_path) + self.assertIn('venv_dir="$repo_root/.venv"', content) + self.assertIn('"$python_cmd" -m venv "$venv_dir"', content) + self.assertIn('venv_python="$venv_dir/bin/python"', content) + self.assertIn('cli="$venv_dir/bin/agent-environment-backup"', content) + self.assertIn('"$venv_python" -m pip install -e "$repo_root"', content) + self.assertIn('"$cli" --profile', content) + self.assertNotIn('"$python_cmd" -m pip install --user -e "$repo_root"', content) + self.assertNotIn("ensurepip --user", content) + self.assertNotIn("--break-system-packages", content) + + def test_posix_install_handles_missing_pip_and_skill_conflicts(self) -> None: + codex_install = self.read(".codex/INSTALL.md") + self.assertIn("python3-venv", codex_install) + self.assertIn('if [ -e "$skill_namespace" ] || [ -L "$skill_namespace" ]; then', codex_install) + self.assertIn('ln -s "$repo_root/skills" "$skill_namespace"', codex_install) + + codex_update = self.read(".codex/UPDATE.md") + self.assertIn('if [ -L "$skill_namespace" ] && [ ! -e "$skill_namespace" ]; then', codex_update) + self.assertIn('codex-environment-backup/SKILL.md', codex_update) + self.assertIn("Existing skill namespace is not a link", codex_update) + + claude_install = self.read(".claude/INSTALL.md") + self.assertIn("python3-venv", claude_install) + self.assertIn('if [ -e "$skill_namespace" ] || [ -L "$skill_namespace" ]; then', claude_install) + self.assertIn('ln -s "$skill_source" "$skill_namespace"', claude_install) + + claude_update = self.read(".claude/UPDATE.md") + self.assertIn('if [ -L "$skill_namespace" ] && [ ! -e "$skill_namespace" ]; then', claude_update) + self.assertIn("Existing skill path is not a link", claude_update) + + def test_posix_uninstall_does_not_require_pip(self) -> None: + for relative_path in (".codex/UNINSTALL.md", ".claude/UNINSTALL.md"): + content = self.read(relative_path) + shell_block = content.split("```bash", 1)[1].split("```", 1)[0] + self.assertNotIn("-m pip", shell_block) + self.assertIn('rm "$skill_namespace"', shell_block) + self.assertIn('rm -rf "$repo_root"', shell_block) + def test_python_discovery_keeps_candidate_fallbacks(self) -> None: for relative_path in (".codex/INSTALL.md", ".codex/UPDATE.md"): content = self.read(relative_path) @@ -77,7 +123,7 @@ def test_python_discovery_keeps_candidate_fallbacks(self) -> None: uninstall = self.read(".codex/UNINSTALL.md") self.assertIn("$LASTEXITCODE", uninstall) - self.assertIn("for candidate in python3 python", uninstall) + self.assertIn("foreach ($candidate in @('python3', 'python'))", uninstall) def test_claude_code_lifecycle_docs_exist(self) -> None: @@ -104,6 +150,8 @@ def test_claude_code_skill_exists_and_has_profile(self) -> None: skill = self.read("skills/claude-code-environment-backup/SKILL.md") self.assertIn("--profile claude-code", skill) self.assertIn("agent_environment_backup", skill) + self.assertIn("~/.claude/agent-environment-backup/.venv/bin/agent-environment-backup", skill) + self.assertIn(" --profile claude-code doctor", skill) self.assertIn("Claude Code", skill) self.assertNotIn("codex_environment_backup", skill) @@ -121,6 +169,8 @@ def test_codex_skill_uses_new_module_name(self) -> None: skill = self.read("skills/codex-environment-backup/SKILL.md") self.assertIn("agent_environment_backup", skill) self.assertIn("--profile codex", skill) + self.assertIn("~/.codex/codex-environment-backup/.venv/bin/agent-environment-backup", skill) + self.assertIn(" --profile codex doctor", skill) self.assertNotIn("restore-codex-environment", skill) self.assertIn("restore-environment.cmd", skill)