---
phase: 060-setup-modularization
plan: 04
type: execute
wave: 2
depends_on:
  - 060-01
  - 060-02
  - 060-03
files_modified:
  - paperforge/setup/plan.py
  - paperforge/setup_wizard.py
  - paperforge/cli.py
autonomous: true
requirements:
  - SETP-01
  - SETP-07

must_haves:
  truths:
    - "SetupPlan class defines 6 setup phases in order: checker -> writer -> vault -> runtime -> agent -> plan"
    - "SetupPlan.execute() runs steps sequentially, collecting SetupStepResult for each step"
    - "SetupPlan.execute() supports --headless mode (no interactive prompts) and --json mode (outputs step array to stdout)"
    - "paperforge setup --headless --json outputs a JSON array of per-step status objects with {step, ok, message, error} fields — each step independent"
    - "The JSON output is parseable by the Obsidian plugin to render step-by-step progress"
    - "setup_wizard.py exists as a thin backward-compat shim that imports from paperforge.setup.plan and re-exports legacy symbols"
    - "cli.py dispatches setup command through SetupPlan/commands.setup instead of direct headless_setup() call"
    - "Existing tests importing headless_setup, AGENT_CONFIGS, EnvChecker, CheckResult from setup_wizard.py continue to pass"
    - "paperforge setup without --headless shows the help message saying TUI is removed and references --headless"
  artifacts:
    - path: "paperforge/setup/plan.py"
      provides: "SetupPlan class — orchestrator, step definitions, headless_setup() public API"
      min_lines: 180
    - path: "paperforge/setup_wizard.py"
      provides: "Backward-compat shim — re-exports all legacy symbols, delegates headless_setup to SetupPlan"
      min_lines: 80
    - path: "paperforge/cli.py"
      provides: "Updated setup command dispatch through new command module"
      min_lines: 10 (delta from 10-line update to setup dispatch block)
  key_links:
    - from: "paperforge/setup/plan.py"
      to: "paperforge/setup/checker.py"
      via: "SetupChecker class instantiation"
      pattern: "SetupChecker"
    - from: "paperforge/setup/plan.py"
      to: "paperforge/setup/config_writer.py"
      via: "ConfigWriter class instantiation"
      pattern: "ConfigWriter"
    - from: "paperforge/setup/plan.py"
      to: "paperforge/setup/vault.py"
      via: "VaultInitializer class instantiation"
      pattern: "VaultInitializer"
    - from: "paperforge/setup/plan.py"
      to: "paperforge/setup/runtime.py"
      via: "RuntimeInstaller class instantiation"
      pattern: "RuntimeInstaller"
    - from: "paperforge/setup/plan.py"
      to: "paperforge/setup/agent.py"
      via: "AgentInstaller class instantiation"
      pattern: "AgentInstaller"
    - from: "paperforge/cli.py"
      to: "paperforge/setup/plan.py"
      via: "import headless_setup for CLI dispatch"
      pattern: "from paperforge.setup.plan import headless_setup"
    - from: "paperforge/setup_wizard.py"
      to: "paperforge/setup/plan.py"
      via: "headless_setup delegation"
      pattern: "from paperforge.setup.plan import headless_setup"

---

<objective>
Create the orchestration layer that wires all 5 setup classes together: `SetupPlan` defines the step sequence, dependency ordering, and execution loop. `setup_wizard.py` becomes a thin backward-compat shim preserving all existing imports. `cli.py` dispatches through the new pipeline with `--headless --json` output producing per-step status.

Purpose: Without SetupPlan, the 5 independent classes cannot execute as a coherent setup flow. Without the CLI integration, users cannot invoke `paperforge setup --headless --json`. Without the backward-compat shim, `tests/test_setup_wizard.py` (512 lines) and any plugin subprocess callers break.

Output:
- `paperforge/setup/plan.py` — `SetupPlan` class + `headless_setup()` public API function
- `paperforge/setup_wizard.py` — rewritten as thin backward-compat shim
- `paperforge/cli.py` — updated setup dispatch
- (Optional) `paperforge/commands/__init__.py` — add setup to command registry
</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-02-PLAN.md
@.planning/phases/060-setup-modularization/060-03-PLAN.md

# All 5 classes to orchestrate
@paperforge/setup/__init__.py      -- SetupStepResult, AGENT_CONFIGS
@paperforge/setup/checker.py       -- SetupChecker
@paperforge/setup/config_writer.py -- ConfigWriter
@paperforge/setup/vault.py         -- VaultInitializer, _find_vault
@paperforge/setup/runtime.py       -- RuntimeInstaller
@paperforge/setup/agent.py         -- AgentInstaller (to be created by Plan 03)

# Files to update
@paperforge/setup_wizard.py -- original monolith (becomes shim)
@paperforge/cli.py           -- setup dispatch needs update (lines 485-504)
@paperforge/commands/__init__.py -- command registry

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

<interfaces>
All 5 class interfaces that SetupPlan orchestrates:

**SetupChecker** (from checker.py):
```python
class SetupChecker:
    def __init__(self, vault: Path)
    system_dir: str = "System"
    manual_zotero_path: Path | None = None
    def check_python(self) -> SetupStepResult
    def check_dependencies(self) -> SetupStepResult
    def check_vault(self) -> SetupStepResult
    def check_zotero(self, manual_path=None) -> SetupStepResult
    def check_bbt(self, manual_path=None) -> SetupStepResult
    def check_json(self) -> SetupStepResult
    def get_exports_dir(self) -> Path
```

**ConfigWriter** (from config_writer.py):
```python
class ConfigWriter:
    def __init__(self, vault: Path, system_dir: str = "System")
    def write_paperforge_json(self, config: dict) -> SetupStepResult
    def build_config(self, version, agent_name, agent_key, skill_dir, command_dir, system_dir, zotero_data, vault_config) -> dict
    def write_domain_config(self) -> SetupStepResult
```

**VaultInitializer** (from vault.py):
```python
class VaultInitializer:
    def __init__(self, vault, system_dir="System", resources_dir="Resources", literature_dir="Literature", control_dir="LiteratureControl", base_dir="Bases")
    def create_directories(self) -> SetupStepResult
    def create_zotero_junction(self, zotero_data=None) -> SetupStepResult
    def merge_env(self, paddleocr_key=None, paddleocr_url="...", zotero_data=None) -> SetupStepResult
```

**RuntimeInstaller** (from runtime.py):
```python
class RuntimeInstaller:
    def __init__(self, sys_executable=None, repo_root=None)
    def install(self, version: str, progress_callback=None) -> SetupStepResult
    def check_pip_available(self) -> SetupStepResult
```

**AgentInstaller** (from agent.py):
```python
class AgentInstaller:
    def __init__(self, vault, agent_key, repo_root, system_dir="System", resources_dir="Resources", literature_dir="Literature", control_dir="LiteratureControl", base_dir="Bases")
    def deploy_skills(self) -> SetupStepResult
    def deploy_worker_scripts(self) -> SetupStepResult
    def deploy_plugin(self) -> SetupStepResult
    def deploy_docs(self) -> SetupStepResult
    def deploy_agents_md(self) -> SetupStepResult
    def create_config_file(self) -> SetupStepResult
```

**SetupStepResult** (from __init__.py):
```python
@dataclass
class SetupStepResult:
    step: str
    ok: bool
    message: str
    error: PFError | None = None

    @classmethod
    def ok(cls, step, message) -> SetupStepResult
    @classmethod
    def fail(cls, step, message, error=None) -> SetupStepResult
    def to_dict(self) -> dict
```

Current CLI setup dispatch (cli.py lines 485-504):
```python
if args.command == "setup":
    if getattr(args, "headless", False):
        from paperforge.setup_wizard import headless_setup
        return headless_setup(
            vault=vault,
            agent_key=args.agent,
            ...
        )
    else:
        from paperforge.setup_wizard import main as run_setup
        return run_setup(sys.argv[2:])
```

Existing test smoke tests (test_setup_wizard.py lines 327-512):
```python
from paperforge.setup_wizard import headless_setup
import paperforge.setup_wizard as sw
monkeypatch.setattr(sw.EnvChecker, "check_dependencies", patched_check_deps)
rv = headless_setup(vault=tmp_path, agent_key="claude", ...)
assert rv == 0
```
</interfaces>

<tasks>

<task type="auto">
  <name>Task 1: Create SetupPlan orchestrator class</name>
  <files>paperforge/setup/plan.py</files>
  <action>
    Create `paperforge/setup/plan.py` with the `SetupPlan` class and a public `headless_setup()` convenience function.

    **Module-level function: `headless_setup(vault, agent_key="opencode", ...) -> int`**
    - Same signature as the current `setup_wizard.headless_setup()` (lines 631-663) for backward compat
    - Parameters: vault, agent_key, paddleocr_key, paddleocr_url, system_dir, resources_dir, literature_dir, control_dir, base_dir, zotero_data, skip_checks, repo_root
    - Creates a `SetupPlan` instance and calls `execute()` with the appropriate flags
    - Returns `0` on success (all steps ok), non-zero on failure (matching current return codes)

    **Class: `SetupPlan`**

    `__init__(self, vault: Path, agent_key: str = "opencode", paddleocr_key: str | None = None, paddleocr_url: str = "https://paddleocr.aistudio-app.com/api/v2/ocr/jobs", system_dir: str = "System", resources_dir: str = "Resources", literature_dir: str = "Literature", control_dir: str = "LiteratureControl", base_dir: str = "Bases", zotero_data: str | None = None, skip_checks: bool = False, repo_root: Path | None = None)`
    - Stores all configuration
    - Auto-detects repo_root if not provided (same logic as headless_setup() lines 670-680)
    - Validates repo_root exists (contains `paperforge/` subdirectory) — returns error if not
    - Validates agent_key is in AGENT_CONFIGS — returns error if not
    - Instantiates all 5 sub-classes:
      - `self.checker = SetupChecker(vault)` with system_dir set
      - `self.writer = ConfigWriter(vault, system_dir)`
      - `self.vault_init = VaultInitializer(vault, system_dir, resources_dir, literature_dir, control_dir, base_dir)`
      - `self.runtime = RuntimeInstaller(sys_executable=sys.executable, repo_root=repo_root)`
      - `self.agent = AgentInstaller(vault, agent_key, repo_root, system_dir, resources_dir, literature_dir, control_dir, base_dir)`

    `execute(self, headless: bool = True, json_output: bool = False) -> int`
    - Defines the 6-step sequence:
      1. **"checker"**: `_run_checker()` — validates preconditions
      2. **"writer"**: `_run_writer()` — creates paperforge.json and domain config
      3. **"vault"**: `_run_vault()` — creates directories, Zotero junction, .env
      4. **"runtime"**: `_run_runtime()` — pip install paperforge
      5. **"agent"**: `_run_agent()` — deploys skills, commands, plugin, docs
      6. **"plan"**: `_run_plan()` — verification step (runs checks to confirm install)
    - Each step calls its private `_run_*` method which returns a `SetupStepResult`
    - Results are accumulated in `self.steps: list[SetupStepResult]`
    - If `json_output=True`: after all steps, prints `json.dumps([s.to_dict() for s in self.steps], ...)` to stdout
    - Steps run sequentially; if a blocking step fails (checker with blocking failures), subsequent steps still run (non-fatal collection matches current behavior where checks are split into blocking and non-blocking)
    - Returns 0 if all steps ok, otherwise the step index (1-indexed) of first failure

    `_run_checker(self) -> SetupStepResult`
    - If `skip_checks=True`, returns `SetupStepResult.ok("checker", "checks skipped")`
    - Calls `checker.check_python()` — if fails, returns fail (blocking)
    - Calls `checker.check_dependencies()` — if fails, returns fail (blocking)
    - Then calls non-blocking checks: `checker.check_vault()`, `check_zotero()`, `check_bbt()`, `check_json()`
    - Packages all results into a single `SetupStepResult` with aggregated message

    `_run_writer(self) -> SetupStepResult`
    - Builds vault_config dict from instance config
    - Calls `writer.build_config(...)` with version from `paperforge.__version__`
    - Calls `writer.write_paperforge_json(config)`
    - Calls `writer.write_domain_config()`
    - Returns combined result

    `_run_vault(self) -> SetupStepResult`
    - Calls `vault_init.create_directories()`
    - Calls `vault_init.create_zotero_junction(zotero_data)`
    - Calls `vault_init.merge_env(paddleocr_key, paddleocr_url, zotero_data)`
    - Returns combined result

    `_run_runtime(self) -> SetupStepResult`
    - Calls `runtime.check_pip_available()` — if fails, returns fail
    - Calls `runtime.install(version=__version__, progress_callback=print)` for progress output in non-JSON mode
    - Returns install result

    `_run_agent(self) -> SetupStepResult`
    - Calls: deploy_worker_scripts, deploy_skills, deploy_plugin, deploy_docs, deploy_agents_md, create_config_file
    - Returns combined result

    `_run_plan(self) -> SetupStepResult`
    - Verification step: checks that all expected artifacts exist (worker scripts, skill files, base dir, exports dir, ocr dir, paperforge.json, obsidian plugin, AGENTS.md)
    - Matches current headless_setup() Phase 7 logic (lines 1021-1037)

    `get_results(self) -> list[SetupStepResult]`
    - Returns accumulated step results (for programmatic access)

    `print_summary(self) -> None`
    - Prints the setup completion banner (matching current lines 1042-1061)

    **Edge cases:**
    - repo_root doesn't contain paperforge/ -> fail at init, not in execution
    - All steps tolerant to partial failure — non-fatal steps log but continue
    - json_output=True prints to stdout (for plugin consumption); non-json output prints human-readable progress
    - skip_checks=True skips checker step entirely (for CI/testing)
  </action>
  <verify>
    <automated>python -c "from paperforge.setup.plan import SetupPlan, headless_setup; print('SetupPlan and headless_setup imported successfully')"</automated>
  </verify>
  <done>SetupPlan class created with 6-step execution sequence; all 5 sub-classes instantiated and orchestrated; headless_setup() public API preserves signature; --json output produces per-step status array</done>
</task>

<task type="auto">
  <name>Task 2: Rewrite setup_wizard.py as backward-compat shim + update cli.py dispatch</name>
  <files>paperforge/setup_wizard.py, paperforge/cli.py</files>
  <action>
    **Part A: Rewrite `paperforge/setup_wizard.py`**

    Replace the 1086-line monolith with a thin backward-compat shim:
    ```python
    \"\"\"PaperForge Setup Wizard — backward-compat shim.

    All logic has been decomposed into paperforge/setup/ package.
    This module forwards imports and calls for backward compatibility.
    \"\"\"

    from __future__ import annotations

    from pathlib import Path
    from typing import Any

    # Re-export all symbols that existing tests import
    from paperforge.setup import AGENT_CONFIGS, SetupStepResult
    from paperforge.setup.checker import SetupChecker

    # Backward-compat: CheckResult used by tests
    class CheckResult:
        \"\"\"Legacy check result — kept for backward compatibility.\"\"\"
        def __init__(self, name: str):
            self.name = name
            self.passed = False
            self.detail = ""
            self.action_required = False

    # Backward-compat: EnvChecker wraps SetupChecker with legacy results
    class EnvChecker:
        \"\"\"Legacy env checker — wraps SetupChecker for backward compat.\"\"\"
        def __init__(self, vault: Path):
            from paperforge.setup.checker import SetupChecker as _SC
            self._checker = _SC(vault)
            self.vault = vault
            self.system_dir = "System"
            self.manual_zotero_path = None
            self.results: dict[str, CheckResult] = {
                "python": CheckResult("Python 版本"),
                "vault": CheckResult("Vault 结构"),
                "zotero": CheckResult("Zotero 安装"),
                "bbt": CheckResult("Better BibTeX"),
                "json": CheckResult("JSON 导出"),
            }

        def get_exports_dir(self) -> Path:
            return self._checker.get_exports_dir()

        def check_python(self) -> CheckResult:
            sr = self._checker.check_python()
            return self._to_legacy("python", sr)

        # ... similar wrappers for check_dependencies, check_vault, check_zotero, check_bbt, check_json
        # _find_zotero delegates to self._checker._find_zotero

        def _to_legacy(self, key: str, sr: SetupStepResult) -> CheckResult:
            r = self.results.get(key, CheckResult(key))
            r.passed = sr.ok
            r.detail = sr.message
            r.action_required = not sr.ok and sr.error is not None
            return r

    # Re-export helpers from vault module
    from paperforge.setup.vault import (
        _find_vault, _substitute_vars, _copy_file_incremental,
        _write_text_incremental, _copy_tree_incremental, _merge_env_incremental,
    )

    # headless_setup delegates to plan module
    def headless_setup(vault, agent_key="opencode", **kwargs) -> int:
        from paperforge.setup.plan import headless_setup as _hs
        return _hs(vault, agent_key=agent_key, **kwargs)

    # main() — unchanged: prints help message about Textual TUI removal
    def main(argv: list[str] | None = None) -> int:
        \"\"\"Print help message — Textual TUI removed.\"\"\"
        print("=" * 60)
        print("  PaperForge Setup Wizard")
        print("=" * 60)
        print()
        print("The interactive Textual TUI has been removed.")
        print()
        print("To run setup non-interactively, use:")
        print("  paperforge setup --headless")
        print()
        print("Or configure PaperForge via the Obsidian plugin settings tab:")
        print("  1. Open Obsidian -> Settings -> Community Plugins -> PaperForge")
        print("  2. Fill in your configuration")
        print("  3. Click 'Install'")
        print()
        return 0
    ```

    The CheckResult class preserves the exact same interface as the original (name, passed, detail, action_required).
    The EnvChecker class wraps SetupChecker and converts SetupStepResult back to CheckResult, so all existing tests pass without modification.
    The headless_setup function has the same signature and delegates to plan.headless_setup().
    The main function preserves the exact same output text.

    IMPORTANT: Preserve `if __name__ == "__main__": raise SystemExit(main())` at the bottom.

    **Part B: Update `paperforge/cli.py` setup dispatch**

    Replace lines 485-504 (the if args.command == "setup" block) with:
    ```python
    if args.command == "setup":
        if getattr(args, "headless", False):
            from paperforge.setup.plan import headless_setup

            return headless_setup(
                vault=vault,
                agent_key=args.agent,
                paddleocr_key=getattr(args, "paddleocr_key", None),
                paddleocr_url=getattr(args, "paddleocr_url",
                    "https://paddleocr.aistudio-app.com/api/v2/ocr/jobs"),
                system_dir=getattr(args, "system_dir", None) or "System",
                resources_dir=getattr(args, "resources_dir", None) or "Resources",
                literature_dir=getattr(args, "literature_dir", None) or "Literature",
                control_dir=getattr(args, "control_dir", None) or "LiteratureControl",
                base_dir=getattr(args, "base_dir", None) or "Bases",
                zotero_data=getattr(args, "zotero_data", None),
                skip_checks=getattr(args, "skip_checks", False),
            )
        else:
            from paperforge.setup_wizard import main as run_setup
            return run_setup(sys.argv[2:])
    ```

    The key change is the import: `from paperforge.setup.plan import headless_setup` instead of `from paperforge.setup_wizard import headless_setup`.

    Also add `literature_dir` and `control_dir` parameters to the headless_setup call — these were missing in the current cli.py dispatch but present in the setup_wizard signature. (This is a minor bug fix in the dispatch.)

    **Part C: Update command registry (optional)**

    In `paperforge/commands/__init__.py`, add setup to the registry:
    ```python
    "setup": "paperforge.commands.setup",
    ```

    If `paperforge/commands/setup.py` doesn't exist yet, it's optional — the CLI dispatches directly. Only update if you also create the command module.
  </action>
  <verify>
    <automated>python -c "
from paperforge.setup_wizard import CheckResult, EnvChecker, AGENT_CONFIGS, _find_vault, headless_setup
import tempfile
from pathlib import Path
d = tempfile.mkdtemp()
r = CheckResult('test')
assert r.name == 'test'
c = EnvChecker(Path(d))
assert c.vault == Path(d)
assert set(c.results.keys()) == {'python', 'vault', 'zotero', 'bbt', 'json'}
py = c.check_python()
assert isinstance(py, CheckResult)
assert py.passed is True
print('All backward-compat assertions passed')
"