refactor(paths): unify global data directory to ~/.evoscientist and u… (#164)

* refactor(paths): unify global data directory to ~/.evoscientist and update related paths

* refactor(paths): update legacy session migration to respect XDG_CONFIG_HOME

* refactor(tests): clear XDG_CONFIG_HOME in legacy session migration tests for deterministic behavior
This commit is contained in:
Xi Zhang
2026-04-18 16:01:22 +02:00
committed by GitHub
parent 7c6b6755f2
commit f4a3617646
11 changed files with 246 additions and 31 deletions
+1 -1
View File
@@ -291,7 +291,7 @@ class MergedSkillsBackend(BackendProtocol):
Priority (high → low):
1. primary — workspace/skills/ (project-local, writable)
2. global — ~/.config/evoscientist/skills/ (user global, read-only)
2. global — ~/.evoscientist/skills/ (user global, read-only)
3. secondary — EvoScientist/skills/ (built-in, PyPI, read-only)
Higher-priority skills override lower-priority skills with the same name.
+1 -1
View File
@@ -1,7 +1,7 @@
"""History-based auto-suggest for Textual TUI Input widget.
Reads prompt_toolkit FileHistory format so Rich CLI and TUI share the same
history file at ~/.config/evoscientist/history.
history file at ~/.evoscientist/history.
"""
from __future__ import annotations
+2 -5
View File
@@ -296,11 +296,8 @@ def cmd_interactive(
memory_dir = str(paths.MEMORIES_DIR)
from ..config.settings import get_config_dir
config_dir = get_config_dir()
config_dir.mkdir(parents=True, exist_ok=True)
history_file = str(config_dir / "history")
paths.DATA_DIR.mkdir(parents=True, exist_ok=True)
history_file = str(paths.DATA_DIR / "history")
# Key bindings: Enter submits, Alt+Enter (Option+Enter) inserts newline
_kb = KeyBindings()
+1 -1
View File
@@ -58,7 +58,7 @@ def _cmd_list_skills() -> None:
def _cmd_install_skill(args: str) -> None:
"""Install a skill from local path or GitHub URL.
By default, installs to the global skills directory (~/.config/evoscientist/skills/).
By default, installs to the global skills directory (~/.evoscientist/skills/).
Append --local to install to the current workspace instead.
Usage: /install-skill <path-or-url> [--local]
+2 -2
View File
@@ -22,7 +22,7 @@ import EvoScientist.cli.channel as _ch_mod
from ..commands import CommandContext
from ..commands import manager as cmd_manager
from ..config.settings import get_config_dir
from ..paths import DATA_DIR
from ..sessions import (
find_similar_threads,
generate_thread_id,
@@ -353,7 +353,7 @@ def run_textual_interactive(
self._picker_future: asyncio.Future | None = None
self._browser_future: asyncio.Future | None = None
self._mcp_browser_future: asyncio.Future | None = None
self._history_suggester = HistorySuggester(get_config_dir() / "history")
self._history_suggester = HistorySuggester(DATA_DIR / "history")
self._history_index: int = -1 # -1 = not browsing history
self._history_saved_input: str = "" # saved current input before browsing
self._background_tasks: set[asyncio.Task] = set()
+100 -10
View File
@@ -2,10 +2,14 @@
from __future__ import annotations
import logging
import os
import shutil
from datetime import datetime
from pathlib import Path
logger = logging.getLogger(__name__)
def _expand(path: str) -> Path:
return Path(path).expanduser()
@@ -26,22 +30,32 @@ USER_SKILLS_DIR = _env_path("EVOSCIENTIST_SKILLS_DIR") or (WORKSPACE_ROOT / "ski
MEDIA_DIR = _env_path("EVOSCIENTIST_MEDIA_DIR") or (WORKSPACE_ROOT / "media")
def _global_data_dir() -> Path:
"""Global application data directory (~/.evoscientist/ by default).
This is the base for sessions.db, skills/, memories/, history — things
that are NOT configuration but application state. Config files (config.yaml,
mcp.yaml) continue to live in XDG_CONFIG_HOME.
"""
return Path.home() / ".evoscientist"
# Global data dir: ~/.evoscientist/ by default, overridable via env var.
DATA_DIR: Path = _env_path("EVOSCIENTIST_DATA_DIR") or _global_data_dir()
def _global_skills_dir() -> Path:
xdg = os.environ.get("XDG_CONFIG_HOME")
base = Path(xdg) if xdg else Path.home() / ".config"
return base / "evoscientist" / "skills"
return DATA_DIR / "skills"
def _global_memories_dir() -> Path:
xdg = os.environ.get("XDG_CONFIG_HOME")
base = Path(xdg) if xdg else Path.home() / ".config"
return base / "evoscientist" / "memories"
return DATA_DIR / "memories"
# Global skills: shared across all workspaces (~/.config/evoscientist/skills/)
# Global skills: shared across all workspaces (~/.evoscientist/skills/)
GLOBAL_SKILLS_DIR: Path = _global_skills_dir()
# Global memories: shared across all workspaces (~/.config/evoscientist/memories/)
# Global memories: shared across all workspaces (~/.evoscientist/memories/)
GLOBAL_MEMORIES_DIR: Path = _global_memories_dir()
# Memories dir: global by default, overridable via env var.
@@ -54,6 +68,80 @@ MEMORIES_DIR: Path = (
MEMORY_DIR = MEMORIES_DIR # backward compat alias
# DEPRECATED(0.1.0): remove this migration helper and its call site below.
def migrate_legacy_sessions_db() -> None:
"""One-time migration: copy sessions.db (and its WAL/SHM siblings) from
~/.config/evoscientist/ to ~/.evoscientist/.
Scope is intentionally narrow — only the SQLite trio, because users can't
easily move those by hand. User-facing files (skills/, memories/, history)
are migrated via an agent prompt documented in the release notes.
Idempotent via ``.migrated`` marker file. The marker is not written when
a copy fails, so transient I/O errors don't permanently block retry.
"""
marker = DATA_DIR / ".migrated"
if marker.exists():
return
try:
DATA_DIR.mkdir(parents=True, exist_ok=True)
except OSError:
logger.debug("Could not create %s; skipping legacy migration.", DATA_DIR)
return
# Resolve legacy source via XDG_CONFIG_HOME (matches config.settings.get_config_dir).
# Inlined here to avoid importing config.settings at paths load time.
xdg = os.environ.get("XDG_CONFIG_HOME")
legacy = (
(Path(xdg) / "evoscientist")
if xdg
else (Path.home() / ".config" / "evoscientist")
)
if not legacy.exists():
marker.touch()
return
migrated: list[str] = []
failed: list[str] = []
for name in ("sessions.db", "sessions.db-wal", "sessions.db-shm"):
src = legacy / name
dst = DATA_DIR / name
if not src.exists():
continue
if dst.exists():
continue # already migrated
try:
shutil.copy2(src, dst)
migrated.append(name)
except OSError as e:
logger.warning("Failed to migrate %s: %s", src, e)
failed.append(name)
if migrated:
logger.info(
"Migrated legacy session DB from %s to %s: %s. "
"Legacy files are kept as backup; this auto-migration will be "
"removed in EvoScientist 0.1.0.",
legacy,
DATA_DIR,
", ".join(migrated),
)
# Only write the marker when there were no failures — preserves retry
# on transient I/O errors.
if not failed:
marker.touch()
# DEPRECATED(0.1.0): remove this call together with migrate_legacy_sessions_db().
try:
migrate_legacy_sessions_db()
except Exception:
# Never block startup on migration failures
logger.exception("Legacy session DB migration failed; continuing without it.")
def set_workspace_root(path: str | Path) -> None:
"""Update workspace root and re-derive dependent directories.
@@ -90,12 +178,14 @@ def set_workspace_root(path: str | Path) -> None:
def ensure_dirs() -> None:
"""Create runtime subdirectories if they do not exist.
Only memories is created eagerly — skills directories are created on demand
by install_skill() when the user first installs a skill.
Creates DATA_DIR (and MEMORIES_DIR as its subdir). Skills directories
are created on demand by ``install_skill()`` when the user first
installs a skill.
Does NOT create the workspace root itself — it should already exist
(either the user's cwd or a directory they specified).
"""
DATA_DIR.mkdir(parents=True, exist_ok=True)
MEMORIES_DIR.mkdir(parents=True, exist_ok=True)
+3 -3
View File
@@ -64,13 +64,13 @@ def _to_short_path(path: str) -> str:
def get_db_path() -> Path:
"""Return the sessions database path, creating parents.
Reuses ``get_config_dir()`` for XDG_CONFIG_HOME support, then applies
Uses ``paths.DATA_DIR`` (~/.evoscientist/ by default), then applies
a best-effort Windows 8.3 short-path conversion on the *directory*
(which exists after ``mkdir``) so sqlite3 can handle non-ASCII paths.
"""
from .config.settings import get_config_dir
from .paths import DATA_DIR
db_dir = get_config_dir()
db_dir = DATA_DIR
db_dir.mkdir(parents=True, exist_ok=True)
return Path(_to_short_path(str(db_dir))) / "sessions.db"
+3 -3
View File
@@ -1,7 +1,7 @@
"""Skill installation and management for EvoScientist.
This module provides functions for installing, listing, and uninstalling user skills.
Skills are installed to GLOBAL_SKILLS_DIR by default (~/.config/evoscientist/skills/).
Skills are installed to GLOBAL_SKILLS_DIR by default (~/.evoscientist/skills/).
Pass global_install=False to install to USER_SKILLS_DIR (<workspace>/skills/) instead.
Supported installation sources:
@@ -278,7 +278,7 @@ def install_skill(
source: Local directory path or GitHub URL/shorthand.
dest_dir: Explicit destination directory (overrides global_install).
global_install: If True (default), install to GLOBAL_SKILLS_DIR
(~/.config/evoscientist/skills/). If False, install to the
(~/.evoscientist/skills/). If False, install to the
workspace-local USER_SKILLS_DIR.
Returns:
@@ -501,7 +501,7 @@ def list_skills(include_system: bool = False) -> list[SkillInfo]:
# Tier 1: workspace-local skills (always highest priority, no dedup needed)
_add_tier(Path(paths.USER_SKILLS_DIR), source="workspace", check_seen=False)
# Tier 2: global skills (~/.config/evoscientist/skills/)
# Tier 2: global skills (~/.evoscientist/skills/)
_add_tier(Path(paths.GLOBAL_SKILLS_DIR), source="global")
# Tier 3: built-in skills (optional)
+3 -2
View File
@@ -10,12 +10,13 @@ from __future__ import annotations
import json
import logging
import time
from pathlib import Path
from .config.settings import get_config_dir
logger = logging.getLogger(__name__)
PYPI_URL = "https://pypi.org/pypi/EvoScientist/json"
CACHE_DIR = Path("~/.config/evoscientist").expanduser()
CACHE_DIR = get_config_dir()
CACHE_FILE = CACHE_DIR / "latest_version.json"
CACHE_TTL = 86_400 # 24 hours
+128
View File
@@ -14,16 +14,22 @@ def _restore_paths():
orig = {
"WORKSPACE_ROOT": paths.WORKSPACE_ROOT,
"RUNS_DIR": paths.RUNS_DIR,
"DATA_DIR": paths.DATA_DIR,
"MEMORIES_DIR": paths.MEMORIES_DIR,
"MEMORY_DIR": paths.MEMORY_DIR,
"GLOBAL_SKILLS_DIR": paths.GLOBAL_SKILLS_DIR,
"GLOBAL_MEMORIES_DIR": paths.GLOBAL_MEMORIES_DIR,
"USER_SKILLS_DIR": paths.USER_SKILLS_DIR,
"_active_workspace": paths._active_workspace,
}
yield
paths.WORKSPACE_ROOT = orig["WORKSPACE_ROOT"]
paths.RUNS_DIR = orig["RUNS_DIR"]
paths.DATA_DIR = orig["DATA_DIR"]
paths.MEMORIES_DIR = orig["MEMORIES_DIR"]
paths.MEMORY_DIR = orig["MEMORY_DIR"]
paths.GLOBAL_SKILLS_DIR = orig["GLOBAL_SKILLS_DIR"]
paths.GLOBAL_MEMORIES_DIR = orig["GLOBAL_MEMORIES_DIR"]
paths.USER_SKILLS_DIR = orig["USER_SKILLS_DIR"]
paths._active_workspace = orig["_active_workspace"]
@@ -120,3 +126,125 @@ class TestEnsureDirsUsesUpdatedPaths:
assert not (
new_root / "skills"
).exists() # skills created on demand by install_skill()
class TestDataDir:
"""Tests for DATA_DIR and global data-dir helpers."""
def test_global_skills_dir_under_data_dir(self):
"""GLOBAL_SKILLS_DIR must live under DATA_DIR."""
assert paths.GLOBAL_SKILLS_DIR == paths.DATA_DIR / "skills"
def test_global_memories_dir_under_data_dir(self):
"""GLOBAL_MEMORIES_DIR must live under DATA_DIR."""
assert paths.GLOBAL_MEMORIES_DIR == paths.DATA_DIR / "memories"
class TestLegacySessionsDbMigration:
"""Tests for migrate_legacy_sessions_db() — transitional helper.
Tests redirect ``DATA_DIR`` and ``Path.home()`` so the real user home
is never touched.
"""
def _setup(self, tmp_path, monkeypatch):
"""Redirect data dir to tmp_path/new_data and Path.home() to
tmp_path/fake_home so legacy resolves to tmp_path/fake_home/.config/evoscientist.
Clears XDG_CONFIG_HOME so the legacy resolver deterministically uses
the Path.home() fallback. Tests that want the XDG branch set the
env var explicitly.
"""
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
data_dir = tmp_path / "new_data"
fake_home = tmp_path / "fake_home"
fake_home.mkdir()
legacy_dir = fake_home / ".config" / "evoscientist"
monkeypatch.setattr(paths, "DATA_DIR", data_dir)
monkeypatch.setattr(paths.Path, "home", classmethod(lambda cls: fake_home))
return data_dir, legacy_dir
def test_copies_sqlite_trio_when_legacy_exists(self, tmp_path, monkeypatch):
"""All three SQLite files should be copied to the new location."""
data_dir, legacy_dir = self._setup(tmp_path, monkeypatch)
legacy_dir.mkdir(parents=True)
for name in ("sessions.db", "sessions.db-wal", "sessions.db-shm"):
(legacy_dir / name).write_bytes(b"stub-" + name.encode())
paths.migrate_legacy_sessions_db()
for name in ("sessions.db", "sessions.db-wal", "sessions.db-shm"):
assert (data_dir / name).read_bytes() == b"stub-" + name.encode()
# Legacy files must remain (copy, not move)
assert (legacy_dir / name).exists()
assert (data_dir / ".migrated").exists()
def test_idempotent_via_marker(self, tmp_path, monkeypatch):
"""Once .migrated exists, migration should be a no-op."""
data_dir, legacy_dir = self._setup(tmp_path, monkeypatch)
data_dir.mkdir()
(data_dir / ".migrated").touch()
legacy_dir.mkdir(parents=True)
(legacy_dir / "sessions.db").write_bytes(b"should-not-copy")
paths.migrate_legacy_sessions_db()
assert not (data_dir / "sessions.db").exists()
def test_no_legacy_just_creates_marker(self, tmp_path, monkeypatch):
"""When legacy dir doesn't exist, only the marker is created."""
data_dir, _ = self._setup(tmp_path, monkeypatch)
paths.migrate_legacy_sessions_db()
assert data_dir.is_dir()
assert (data_dir / ".migrated").exists()
assert not (data_dir / "sessions.db").exists()
def test_does_not_overwrite_existing_files(self, tmp_path, monkeypatch):
"""If new location already has a file, don't overwrite it."""
data_dir, legacy_dir = self._setup(tmp_path, monkeypatch)
data_dir.mkdir()
(data_dir / "sessions.db").write_bytes(b"new-content-keep")
legacy_dir.mkdir(parents=True)
(legacy_dir / "sessions.db").write_bytes(b"legacy-content")
paths.migrate_legacy_sessions_db()
assert (data_dir / "sessions.db").read_bytes() == b"new-content-keep"
def test_respects_xdg_config_home(self, tmp_path, monkeypatch):
"""Legacy source must honor XDG_CONFIG_HOME so users who customize it
don't silently get skipped by the migration."""
data_dir = tmp_path / "new_data"
xdg = tmp_path / "xdg"
legacy_dir = xdg / "evoscientist"
legacy_dir.mkdir(parents=True)
(legacy_dir / "sessions.db").write_bytes(b"xdg-db")
monkeypatch.setattr(paths, "DATA_DIR", data_dir)
monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg))
paths.migrate_legacy_sessions_db()
assert (data_dir / "sessions.db").read_bytes() == b"xdg-db"
def test_marker_not_written_on_partial_failure(self, tmp_path, monkeypatch):
"""If any copy fails, the .migrated marker must not be written."""
data_dir, legacy_dir = self._setup(tmp_path, monkeypatch)
legacy_dir.mkdir(parents=True)
for name in ("sessions.db", "sessions.db-wal"):
(legacy_dir / name).write_bytes(b"ok")
real_copy2 = paths.shutil.copy2
def flaky_copy2(src, dst, *args, **kwargs):
if str(src).endswith("sessions.db-wal"):
raise OSError("simulated I/O failure")
return real_copy2(src, dst, *args, **kwargs)
with mock.patch.object(paths.shutil, "copy2", side_effect=flaky_copy2):
paths.migrate_legacy_sessions_db()
assert (data_dir / "sessions.db").exists() # main db copied
assert not (data_dir / ".migrated").exists() # retry allowed
+2 -3
View File
@@ -41,11 +41,10 @@ class TestGenerateThreadId(unittest.TestCase):
class TestGetDbPath(unittest.TestCase):
def test_uses_config_dir(self):
def test_uses_data_dir(self):
path = get_db_path()
assert str(path).endswith("sessions.db")
assert ".config" in str(path)
assert "evoscientist" in str(path)
assert ".evoscientist" in str(path)
class TestFormatRelativeTime(unittest.TestCase):