---
phase: 060-setup-modularization
plan: 02
type: execute
wave: 1
depends_on:
  - 060-01
files_modified:
  - paperforge/setup/vault.py
  - paperforge/setup/runtime.py
autonomous: true
requirements:
  - SETP-03
  - SETP-04

must_haves:
  truths:
    - "VaultInitializer creates PaperForge directory structure (exports/ ocr/ config/ worker/scripts/ literature/ bases/ .obsidian/plugins/paperforge/) — all with mkdir(parents=True, exist_ok=True)"
    - "VaultInitializer creates Zotero junction on Windows (mklink /J) or symlink on POSIX — non-fatal on failure, logs warning"
    - "VaultInitializer merges .env file intelligently: creates if missing, appends only missing keys if exists (preserving user tokens)"
    - "VaultInitializer provides helper methods: _find_vault (walk parent dirs for paperforge.json), _substitute_vars (template variable replacement), incremental file/directory copy functions"
    - "RuntimeInstaller runs pip install with explicit version pinning from plugin manifest — produces progress output via callback"
    - "RuntimeInstaller detects whether running from source repo (has pyproject.toml) or installed package — installs from local path or GitHub tagged release accordingly"
    - "RuntimeInstaller classifies errors using ErrorCode enum (PIP_INSTALL_FAILED, VERSION_MISMATCH, NETWORK_ERROR) — not bare strings"
    - "Existing tests importing _find_vault, _merge_env_incremental from paperforge.setup_wizard continue to pass via backward-compat re-exports"
  artifacts:
    - path: "paperforge/setup/vault.py"
      provides: "VaultInitializer class + helper functions (_find_vault, _substitute_vars, _copy_file_incremental, _write_text_incremental, _copy_tree_incremental, _merge_env_incremental)"
      min_lines: 180
    - path: "paperforge/setup/runtime.py"
      provides: "RuntimeInstaller class — pip install with version pin, progress callback, ErrorCode classification"
      min_lines: 80
  key_links:
    - from: "paperforge/setup/vault.py"
      to: "paperforge/setup_wizard.py"
      via: "backward-compat re-export of _find_vault"
      pattern: "from paperforge.setup.vault import"
    - from: "paperforge/setup/vault.py"
      to: "VaultInitializer.create_directories"
      via: "calls Path.mkdir on each target directory"
      pattern: "mkdir"
    - from: "paperforge/setup/runtime.py"
      to: "subprocess.run for pip install"
      via: "sys.executable -m pip install --upgrade"
      pattern: "subprocess\\.run"
    - from: "paperforge/setup/runtime.py"
      to: "paperforge/core/errors.py"
      via: "ErrorCode classification"
      pattern: "ErrorCode\\."

---

<objective>
Create the vault structure and runtime installation modules: `VaultInitializer` handles all directory creation, Zotero junction setup, and .env file merging — extracted from `headless_setup()` inline code and helper functions. `RuntimeInstaller` handles `pip install` with version pinning, progress callbacks, and ErrorCode-classified error reporting.

Purpose: VaultInitializer owns all filesystem structure creation so other classes never touch directories directly. RuntimeInstaller encapsulates pip logic so version pinning and error classification are in one place rather than inline shell commands.

Output:
- `paperforge/setup/vault.py` — `VaultInitializer` class + helper functions (`_find_vault`, `_substitute_vars`, `_copy_file_incremental`, `_write_text_incremental`, `_copy_tree_incremental`, `_merge_env_incremental`)
- `paperforge/setup/runtime.py` — `RuntimeInstaller` class
</objective>

<execution_context>
../.opencode/get-shit-done/workflows/execute-plan.md
../.opencode/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/phases/060-setup-modularization/060-01-PLAN.md
@.planning/phases/060-setup-modularization/060-CONTEXT.md

# Existing code being decomposed (vault + runtime sections)
@paperforge/setup_wizard.py

# The SetupStepResult type this plan depends on
@paperforge/setup/__init__.py

# Existing tests that MUST continue passing
@tests/test_setup_wizard.py

# Error codes for RuntimeInstaller
@paperforge/core/errors.py
</context>

<interfaces>
SetupStepResult dataclass (from Plan 01):
```python
@dataclass
class SetupStepResult:
    step: str
    ok: bool
    message: str
    error: PFError | None = None
    
    @classmethod
    def ok(cls, step: str, message: str) -> SetupStepResult: ...
    @classmethod
    def fail(cls, step: str, message: str, error: PFError | None = None) -> SetupStepResult: ...
```

ErrorCode enum (from paperforge/core/errors.py):
```python
class ErrorCode(str, Enum):
    PYTHON_NOT_FOUND = "PYTHON_NOT_FOUND"
    VERSION_MISMATCH = "VERSION_MISMATCH"
    BBT_EXPORT_NOT_FOUND = "BBT_EXPORT_NOT_FOUND"
    OCR_TOKEN_MISSING = "OCR_TOKEN_MISSING"
    SYNC_FAILED = "SYNC_FAILED"
    VALIDATION_ERROR = "VALIDATION_ERROR"
    INTERNAL_ERROR = "INTERNAL_ERROR"
    UNKNOWN = "UNKNOWN"
```

Existing helpers being extracted (from setup_wizard.py):
```python
def _find_vault() -> Path | None:
    """Find vault by looking for paperforge.json in current or parent dirs."""
    
def _substitute_vars(text, system_dir, resources_dir, literature_dir, control_dir, base_dir, skill_dir, prefix="/"):
    """Substitute <system_dir>, <resources_dir>, etc. placeholders."""

def _copy_file_incremental(src: Path, dst: Path) -> bool:
    """Copy file only if destination missing. Returns True if copied."""

def _write_text_incremental(dst: Path, text: str) -> bool:
    """Write text file only if destination missing. Returns True if written."""

def _copy_tree_incremental(src_dir: Path, dst_dir: Path) -> tuple[int, int]:
    """Copy directory tree without overwriting. Returns (created, skipped)."""

def _merge_env_incremental(env_path: Path, values: dict[str, str]) -> str:
    """Create .env if missing, append only missing keys. Returns status string."""
```
</interfaces>

<tasks>

<task type="auto">
  <name>Task 1: Create VaultInitializer class with helper functions</name>
  <files>paperforge/setup/vault.py</files>
  <action>
    Create `paperforge/setup/vault.py` with:

    **Module-level helper functions** (extracted verbatim from setup_wizard.py):

    1. `_find_vault() -> Path | None` — exact copy from lines 408-414. Walks current dir and parents looking for `paperforge.json`.
    
    2. `_substitute_vars(text, system_dir, resources_dir, literature_dir, control_dir, base_dir, skill_dir, prefix="/") -> str` — exact copy from lines 417-438. Replaces `<system_dir>`, `<resources_dir>`, etc. placeholders.
    
    3. `_copy_file_incremental(src: Path, dst: Path) -> bool` — exact copy from lines 441-447. Copies only if dst missing. Returns True if copied.
    
    4. `_write_text_incremental(dst: Path, text: str) -> bool` — exact copy from lines 450-456. Writes only if dst missing.
    
    5. `_copy_tree_incremental(src_dir: Path, dst_dir: Path) -> tuple[int, int]` — exact copy from lines 459-473. Walks src tree, calls `_copy_file_incremental`. Returns (created, skipped).
    
    6. `_merge_env_incremental(env_path: Path, values: dict[str, str]) -> str` — exact copy from lines 476-512. Creates .env with PADDLEOCR keys, appends only missing keys if exists. Returns "created", "preserved", or "extended".

    Keep these as module-level functions (not methods on a class) so they remain independently testable.

    **Class: `VaultInitializer`**

    `__init__(self, vault: Path, system_dir: str = "System", resources_dir: str = "Resources", literature_dir: str = "Literature", control_dir: str = "LiteratureControl", base_dir: str = "Bases")`
    - Stores all directory names as instance attributes

    `create_directories(self) -> SetupStepResult`
    - Creates ALL directories matching current headless_setup() Phase 2 (lines 725-737):
      - `<vault>/<system_dir>/PaperForge/exports`
      - `<vault>/<system_dir>/PaperForge/ocr`
      - `<vault>/<system_dir>/PaperForge/config`
      - `<vault>/<system_dir>/PaperForge/worker/scripts`
      - `<vault>/<resources_dir>/<literature_dir>`
      - `<vault>/<base_dir>`
      - `<vault>/.obsidian/plugins/paperforge`
    - All use `mkdir(parents=True, exist_ok=True)`
    - Returns `SetupStepResult.ok("vault", f"{count} directories ready")`

    `create_zotero_junction(self, zotero_data: str | None) -> SetupStepResult`
    - Extracted from headless_setup() lines 740-760
    - If zotero_data is None/empty, returns `SetupStepResult.ok("vault", "Zotero junction skipped")`
    - Creates junction at `<vault>/<system_dir>/Zotero`
    - On Windows: runs `cmd /c mklink /J <target> <zotero_data>`
    - On POSIX: calls `target.symlink_to(zotero_data, target_is_directory=True)`
    - Non-fatal on failure — returns `SetupStepResult.ok` with warning in message (matching current behavior)
    - Returns `SetupStepResult.ok("vault", "Zotero junction created")` on success

    `merge_env(self, paddleocr_key: str | None, paddleocr_url: str = "https://...", zotero_data: str | None = None) -> SetupStepResult`
    - Extracted from headless_setup() lines 937-948
    - Calls `_merge_env_incremental(vault / ".env", values_dict)`
    - Uses `paddleocr_key` or falls back to `os.environ.get("PADDLEOCR_API_TOKEN", "")`
    - Returns `SetupStepResult.ok("vault", f".env ({status})")`

    **Backward compat in setup_wizard.py**: Add these imports:
    ```python
    from paperforge.setup.vault import _find_vault, _substitute_vars, _copy_file_incremental, _write_text_incremental, _copy_tree_incremental, _merge_env_incremental
    ```

    **Edge cases:**
    - _find_vault: no paperforge.json in any parent returns None
    - _copy_tree_incremental: src_dir has subdirectories -> creates dirs, processes files; empty dir -> no files copied
    - _merge_env_incremental: .env exists with all keys -> returns "preserved"; .env missing -> creates with header + values; partial keys -> appends missing
    - Zotero junction: Windows without admin rights -> subprocess fails, returns warning, does NOT crash
  </action>
  <verify>
    <automated>python -c "from paperforge.setup.vault import VaultInitializer, _find_vault, _copy_file_incremental, _write_text_incremental, _copy_tree_incremental, _merge_env_incremental, _substitute_vars; import tempfile; d = tempfile.mkdtemp(); from pathlib import Path; v = VaultInitializer(Path(d)); r = v.create_directories(); assert r.ok; assert (Path(d) / 'System' / 'PaperForge' / 'exports').exists()"</automated>
  </verify>
  <done>VaultInitializer class created with create_directories/create_zotero_junction/merge_env methods; all 6 helper functions extracted as module-level functions; backward-compat re-exports added to setup_wizard.py</done>
</task>

<task type="auto">
  <name>Task 2: Create RuntimeInstaller class with version pinning</name>
  <files>paperforge/setup/runtime.py</files>
  <action>
    Create `paperforge/setup/runtime.py` with the `RuntimeInstaller` class. This extracts the pip install logic from `headless_setup()` Phase 6 (lines 987-1016 of setup_wizard.py).

    **Class: `RuntimeInstaller`**

    `__init__(self, sys_executable: str | None = None, repo_root: Path | None = None)`
    - `sys_executable`: defaults to `sys.executable`
    - `repo_root`: auto-detected from caller context (same logic as current headless_setup() lines 670-680)

    `_auto_detect_repo_root(self) -> Path`:
    - Same detection logic as headless_setup() lines 670-680:
      1. Start from `__file__` parent
      2. If `paperforge` subdirectory exists at that level -> use it
      3. Else if parent has `paperforge/` -> use parent
      4. Else use parent

    `install(self, version: str, progress_callback: Callable[[str], None] | None = None) -> SetupStepResult`
    - step="runtime"
    - Checks if repo_root has `pyproject.toml`:
      - If yes: install target = `[str(repo_root)]` (source install)
      - If no: install target = `[f"git+https://github.com/LLLin000/PaperForge.git@{version}"]` (tagged release from GitHub)
    - Runs `subprocess.run([sys_exe, "-m", "pip", "install", "--upgrade"] + install_target, capture_output=True, text=True, timeout=120)`
    - If progress_callback is provided, calls it with status messages:
      - `progress_callback("Installing paperforge {version}...")` before pip
      - `progress_callback("paperforge {old_ver} -> {new_ver}")` on success
      - `progress_callback("pip install failed: {stderr_short}")` on failure
    - On success: also runs a version check subprocess to report old->new version
    - On failure: returns `SetupStepResult.fail(...)` with ErrorCode.INTERNAL_ERROR and truncated stderr as message (first 500 chars)
    - On exception (timeout, etc): returns `SetupStepResult.fail(...)` with ErrorCode.UNKNOWN and exception message

    `check_pip_available(self) -> SetupStepResult`
    - Runs `subprocess.run([sys_exe, "-m", "pip", "--version"], capture_output=True, timeout=15)`
    - Returns `SetupStepResult.ok("runtime", f"pip {version_output}")` on success
    - Returns `SetupStepResult.fail("runtime", msg, PFError(ErrorCode.INTERNAL_ERROR))` on failure

    **Edge cases:**
    - pip not installed: check_pip_available returns ok=False
    - pip install timeout (>120s): subprocess.TimeoutExpired caught, returns classified error
    - repo_root not found: auto-detect falls back gracefully (doesn't crash)
    - pip install succeeds but version check fails: main install result is still success, version shows "?"
    - Network error during pip install: captured in stderr truncated message
  </action>
  <verify>
    <automated>python -c "from paperforge.setup.runtime import RuntimeInstaller; r = RuntimeInstaller(); pip_check = r.check_pip_available(); assert pip_check.step == 'runtime'"</automated>
  </verify>
  <done>RuntimeInstaller class created with install() and check_pip_available() methods; version pinning via plugin manifest; progress_callback support; ErrorCode-classified errors; repo_root auto-detection logic extracted</done>
</task>

</tasks>

<verification>
1. `python -c "from paperforge.setup.vault import VaultInitializer, _find_vault, _copy_file_incremental, _write_text_incremental, _copy_tree_incremental, _merge_env_incremental, _substitute_vars"` succeeds
2. `python -c "from paperforge.setup.runtime import RuntimeInstaller"` succeeds
3. `python -c "from paperforge.setup_wizard import _find_vault, _merge_env_incremental"` succeeds (backward compat)
4. VaultInitializer.create_directories actually creates the expected directories
5. _merge_env_incremental: create new .env, append missing keys, preserve existing keys
6. RuntimeInstaller.check_pip_available returns SetupStepResult (ok or fail depending on environment)
</verification>

<success_criteria>
1. VaultInitializer creates all 7 directories via create_directories()
2. VaultInitializer creates Zotero junction/symlink via create_zotero_junction()
3. VaultInitializer merges .env via merge_env() — preserves existing keys, appends missing
4. All 6 helper functions (_find_vault, _substitute_vars, _copy_file_incremental, _write_text_incremental, _copy_tree_incremental, _merge_env_incremental) extracted and independently importable
5. RuntimeInstaller.check_pip_available() validates pip is present
6. RuntimeInstaller.install() runs pip with version pinning from plugin manifest
7. install() accepts optional progress_callback for status reporting
8. install() returns classified SetupStepResult with ErrorCode on failure
9. Backward-compat imports from setup_wizard.py pass for _find_vault and _merge_env_incremental
</success_criteria>

<output>
After completion, create `.planning/phases/060-setup-modularization/060-02-SUMMARY.md`
</output>
