---
phase: 21-one-click-install-and-polished-ux
plan: 02
type: execute
wave: 2
depends_on: [21-01]
files_modified:
  - paperforge/plugin/main.js
autonomous: true
requirements: [INST-01, INST-02, INST-04]
user_setup: []

must_haves:
  truths:
    - "User clicks Install with valid settings — full setup executes: paperforge.json written, directories created, env checks pass, agent configs generated"
    - "During setup execution, the Install button is disabled and shows '正在安装...' — clicking it produces no duplicate subprocess"
    - "User sees step-by-step Chinese notice toasts throughout setup progress ('正在创建目录... ✓', '正在写入配置文件... ✓') — raw Python tracebacks are never shown in Obsidian notices"
    - "After setup completes (success or failure), the sidebar PaperForgeStatusView panel and command palette actions continue working normally"
    - "On setup failure, user sees a friendly Chinese error message mapped from common exit patterns"
  artifacts:
    - path: "paperforge/plugin/main.js"
      provides: "_runSetup() subprocess orchestration + _showNotice() + _formatSetupError() + _processSetupOutput() + _setStatus()"
      min_lines: 60
      contains: "_runSetup method with spawn() call, button disable/enable lifecycle, stdout/stderr handling"
  key_links:
    - from: "Install button onClick"
      to: "_runSetup()"
      via: "onClick handler calls this._runSetup(button)"
      pattern: "onClick.*_runSetup"
    - from: "_runSetup()"
      to: "_validate()"
      via: "calls this._validate() first — returns early with notice if errors found"
      pattern: "_validate"
    - from: "_runSetup()"
      to: "CLI: python -m paperforge setup --headless"
      via: "spawn('python', ['-m', 'paperforge', 'setup', '--headless', ...])"
      pattern: "spawn.*setup.*--headless"
    - from: "Subprocess stdout"
      to: "_processSetupOutput()"
      via: "child.stdout.on('data') calls this._processSetupOutput(text)"
      pattern: "_processSetupOutput"
    - from: "Subprocess error"
      to: "_formatSetupError()"
      via: "catch block calls this._formatSetupError(err.message)"
      pattern: "_formatSetupError"

---

<objective>
Add subprocess orchestration and human-readable Chinese notice formatting for the one-click install workflow.

**Purpose:** Wire the install button (Plan 01) to the actual `paperforge setup --headless` CLI command via `node:child_process.spawn`. Provide friendly step-by-step Chinese feedback through Obsidian notices and the status area. Never expose raw Python tracebacks to the user.

**Output:**
- `paperforge/plugin/main.js` — `_runSetup()`, `_showNotice()`, `_formatSetupError()`, `_processSetupOutput()`, `_setStatus()` methods added to `PaperForgeSettingTab`
</objective>

<execution_context>
@C:/Users/Lin/.opencode/get-shit-done/workflows/execute-plan.md
@C:/Users/Lin/.opencode/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md — v1.5 milestone, key decisions
@.planning/STATE.md — Phase 21 decisions, blockers/concerns (Windows path encoding, double-click prevention)
@.planning/phases/21-one-click-install-and-polished-ux/21-01-PLAN.md — Plan 01 created this plan's dependencies

### Dependencies on Plan 01

Plan 01 provides (and Plan 02 consumes):
- `PaperForgeSettingTab._validate()` — returns string[] of errors, empty = valid
- `PaperForgeSettingTab._statusArea` — div element for status text display, created with class `paperforge-install-status`
- CSS classes: `.paperforge-install-success`, `.paperforge-install-error`, `.paperforge-install-progress`
- Install button with `onClick(() => this._runSetup(button))` — button object is the Obsidian Setting Button, has `.setDisabled(bool)` and `.setButtonText(str)` methods

### CLI Interface (already exists)

```bash
# Available command — NO development needed on Python side
python -m paperforge setup --headless --vault <path> --paddleocr-key <key> \
    --system-dir <dir> --resources-dir <dir> --literature-dir <dir> \
    --control-dir <dir> --agent opencode
```

The `headless_setup()` function (in `paperforge/setup_wizard.py` line 1694) runs 7 phases:
1. Pre-flight checks (python, dependencies)
2. Create directories
3. Environment checks (non-blocking)
4. Deploy files (worker scripts, skills, AGENTS.md)
5. Create config files (.env, domain-collections.json, paperforge.json)
6. pip install
7. Verify installation

**Key behavioral details (from reading setup_wizard.py):**
- API key is read via `--paddleocr-key` CLI flag, NOT from `PADDLEOCR_API_TOKEN` env var
- Directory defaults in headless_setup differ from plugin defaults: resources_dir="03_Resources" (vs plugin's "20_Resources"), control_dir="LiteratureControl" (vs plugin's "Control")
- These defaults must be OVERRIDDEN by passing explicit --resources-dir and --control-dir matching plugin settings
- stdout is the progress channel — lines with "[*]" markers indicate step transitions
- stderr is the error channel — error messages written to stderr

### Windows Path Handling

Per STATE.md Blockers/Concerns:
- Windows paths with spaces and Unicode require `spawn` with proper quoting (already handled by `spawn` — unlike `exec`, it passes args as array, no shell interpretation)
- `process.env` passes through existing PATH so Python is found
</context>

<interfaces>
From `paperforge/plugin/main.js` (existing + Plan 01 additions):

```javascript
// Plan 01 additions (available in this plan):
class PaperForgeSettingTab extends PluginSettingTab {
    display() {
        // ... existing 3 sections ...
        // NEW: 安装配置 section with:
        //   this._statusArea = containerEl.createEl('div', { cls: 'paperforge-install-status' });
        //   this._statusArea.setText('填写上方配置后，点击下方按钮一键安装');
        //   button.setButtonText('安装配置').setCta().onClick(() => this._runSetup(button));
    }

    _validate() {
        // Returns string[] of errors, empty = valid
        // Checks: vault_path, system_dir, resources_dir, literature_dir,
        //         control_dir, agent_config_dir, paddleocr_api_key
        // zotero_data_dir is optional — NOT validated
    }
}

// Obsidian Notice API:
// new Notice(message: string, duration?: number)
// Used for toast notifications at the top of the Obsidian window

// Setting Button API (from Obsidian):
// button.setDisabled(bool) — enables/disables the button
// button.setButtonText(str) — changes the button label
// button.setCta() — makes it a call-to-action style
```

From `paperforge/setup_wizard.py`:

```python
def headless_setup(
    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 = "99_System",
    resources_dir: str = "03_Resources",
    literature_dir: str = "Literature",
    control_dir: str = "LiteratureControl",
    base_dir: str = "05_Bases",
    zotero_data: str | None = None,
    skip_checks: bool = False,
    repo_root: Path | None = None,
) -> int:
    # Returns 0 on success, non-zero on failure with messages on stderr
```
</interfaces>

<tasks>

<task type="auto">
  <name>Task 1: Add _runSetup() subprocess orchestration</name>

  <files>paperforge/plugin/main.js</files>

  <read_first>
  paperforge/plugin/main.js — Read the full file, especially:
  - `PaperForgeSettingTab` class (lines 236-297) — where new methods will be added
  - `_debouncedSave()` (line 293) — place new methods after this, before the closing `}`
  - Verify `_validate()` exists (Plan 01 adds it)
  - Note the `const { exec } = require('node:child_process');` at line 2 — this plan adds `spawn` from the same module
  - Note `ACTIONS` dispatch pattern in `PaperForgePlugin.onload()` (line 314-330) — this verifies the sidebar/commands pattern that must remain unchanged (INST-04)
  </read_first>

  <action>
  Add the `_runSetup(button)` async method to `PaperForgeSettingTab`. Place it after `_debouncedSave()` (after line ~296), before the closing `}` of the class.

  ```javascript
  async _runSetup(button) {
      const errors = this._validate();
      if (errors.length > 0) {
          this._showNotice('error', '配置验证失败', errors.join('；'));
          return;
      }

      button.setDisabled(true);
      button.setButtonText('正在安装...');
      this._setStatus('正在配置 PaperForge 环境...', 'progress');

      const { spawn } = require('node:child_process');
      const s = this.plugin.settings;

      // Build CLI args matching the CLI's setup --headless interface
      const args = [
          '-m', 'paperforge', 'setup', '--headless',
          '--vault', s.vault_path.trim(),
          '--paddleocr-key', s.paddleocr_api_key.trim(),
          '--system-dir', s.system_dir.trim(),
          '--resources-dir', s.resources_dir.trim(),
          '--literature-dir', s.literature_dir.trim(),
          '--control-dir', s.control_dir.trim(),
          '--agent', 'opencode',
      ];

      // Add optional zotero_data_dir if filled in
      if (s.zotero_data_dir && s.zotero_data_dir.trim()) {
          args.push('--zotero-data', s.zotero_data_dir.trim());
      }

      try {
          const result = await new Promise((resolve, reject) => {
              const child = spawn('python', args, {
                  cwd: s.vault_path.trim(),
                  env: process.env,
                  timeout: 120000,
              });

              let stdout = '';
              let stderr = '';

              child.stdout.on('data', (data) => {
                  const text = data.toString('utf-8');
                  stdout += text;
                  this._processSetupOutput(text);
              });

              child.stderr.on('data', (data) => {
                  stderr += data.toString('utf-8');
              });

              child.on('close', (code) => {
                  if (code === 0) {
                      resolve({ stdout, stderr });
                  } else {
                      reject(new Error(stderr || `exit code ${code}`));
                  }
              });

              child.on('error', (err) => {
                  reject(err);
              });
          });

          this._showNotice('success', '配置完成', 'PaperForge 安装配置已完成！现可运行同步和 OCR 命令。');
          this._setStatus('配置完成！', 'success');
      } catch (err) {
          // Log full error to console for debugging
          console.error('PaperForge setup failed:', err.message);
          this._showNotice('error', '配置失败', this._formatSetupError(err.message));
          this._setStatus('配置失败，请检查设置后重试', 'error');
      } finally {
          button.setDisabled(false);
          button.setButtonText('安装配置');
      }
  }
  ```

  **CRITICAL design decisions (per Phase 21 decisions in STATE.md):**
  1. Use `spawn` (NOT `exec`): `spawn` streams stdout line-by-line so `_processSetupOutput()` can show step progress. `exec` buffers all output — no intermediate feedback. (Per STATE.md: "Subprocess orchestration uses node:child_process.spawn (not exec) for non-blocking setup execution with stdout/stderr parsing.")
  2. Use `--headless` (NOT `--non-interactive`): The CLI defines `--headless` flag. The existing plan incorrectly used `--non-interactive` — this is a bug fix.
  3. Pass API key via `--paddleocr-key` flag (NOT env var): The `headless_setup()` function reads API key from the CLI argument, not from `PADDLEOCR_API_TOKEN` env var. (Confirmed by reading `cli.py` line 424-440: `paddleocr_key=getattr(args, "paddleocr_key", None)`)
  4. Pass directory settings explicitly: Plugin's defaults (20_Resources, Control) differ from headless_setup's built-in defaults (03_Resources, LiteratureControl). Must override via `--resources-dir` and `--control-dir`.
  5. `cwd: s.vault_path` gives the subprocess the vault root as working directory, which `resolve_vault()` uses as fallback if no `--vault` arg. BUT we also pass `--vault` explicitly for reliability.
  6. Button disable in try/finally: Button is disabled BEFORE `await`, re-enabled in `finally` — guarantees no double-click even if spawn throws synchronously.
  7. `process.env` is passed through so Python PATH resolution works on the user's system.
  </action>

  <acceptance_criteria>
  - grep -n "_runSetup" paperforge/plugin/main.js returns 2+ matches (declaration + reference in display())
  - grep -n "spawn" paperforge/plugin/main.js returns 1+ matches (within _runSetup)
  - grep -n "--headless" paperforge/plugin/main.js returns 1 match
  - grep -n "paddleocr-key" paperforge/plugin/main.js returns 1 match
  - grep -n "setDisabled" paperforge/plugin/main.js returns 2+ matches (disable + re-enable)
  - grep -n "正在安装" paperforge/plugin/main.js returns 1 match (button text during install)
  - grep -n "配置完成" paperforge/plugin/main.js returns 1 match (success notice)
  - grep -n "配置失败" paperforge/plugin/main.js returns 1 match (error notice)
  - The words `--non-interactive` MUST NOT appear anywhere in paperforge/plugin/main.js
  </acceptance_criteria>

  <verify>
  <automated>python -c "
with open('paperforge/plugin/main.js') as f:
    content = f.read()
    checks = [
        ('_runSetup method defined', '_runSetup' in content),
        ('Uses spawn', 'spawn' in content),
        ('Uses --headless (not --non-interactive)', '--headless' in content and '--non-interactive' not in content),
        ('API key via --paddleocr-key', 'paddleocr-key' in content),
        ('Button disable on start', content.count('setDisabled') >= 2),
        ('正在安装 button text', '正在安装' in content),
        ('try/finally block', 'finally' in content.split('_runSetup')[1].split('_showNotice')[0] if '_runSetup' in content else ''),
        ('--vault passed explicitly', '--vault' in content),
        ('--resources-dir passed explicitly', 'resources-dir' in content),
        ('--control-dir passed explicitly', 'control-dir' in content),
        ('console.error for debugging', 'console.error' in content),
    ]
    for name, ok in checks:
        print(f'  [{\"OK\" if ok else \"FAIL\"}] {name}')
    assert all(ok for _, ok in checks), 'Some checks failed'
    print('ALL CHECKS PASSED')
"</automated>
  </verify>

  <done>
  `PaperForgeSettingTab._runSetup(button)` validates fields, spawns `python -m paperforge setup --headless` with explicit args, disables button during execution, shows success/error notices, and re-enables button in `finally`. No `--non-interactive` references.
  </done>
</task>

<task type="auto">
  <name>Task 2: Add notice formatting and status display helpers</name>

  <files>paperforge/plugin/main.js</files>

  <read_first>
  paperforge/plugin/main.js — Read the full `PaperForgeSettingTab` class where these helpers will be added, after `_runSetup()`.
  </read_first>

  <action>
  Add four helper methods to `PaperForgeSettingTab`. Place them after the `_runSetup()` method (added in Task 1), before the closing `}` of the class.

  ```javascript
  _showNotice(type, title, detail) {
      const prefix = { success: '[OK]', error: '[!!]', progress: '[...]' };
      const duration = type === 'error' ? 8000 : 4000;
      new Notice(`${prefix[type] || ''} ${title}\n${detail}`, duration);
  }

  _formatSetupError(raw) {
      // Map common subprocess errors to friendly Chinese messages.
      // Raw stderr is logged to console for debugging; user sees only the mapped message.
      const patterns = [
          { match: /command not found|No such file|not recognized/i, msg: '未找到 Python 环境，请确保已安装 Python 并加入 PATH' },
          { match: /paperforge.*not found|cannot import|ModuleNotFoundError|No module named/i, msg: '未安装 PaperForge 包，请先运行 pip install paperforge' },
          { match: /permission denied|EACCES/i, msg: '权限不足，无法创建目录或写入文件' },
          { match: /ENOENT/i, msg: '路径不存在，请检查 Vault 路径是否正确' },
          { match: /timeout|timed out/i, msg: '操作超时，请检查网络连接后重试' },
      ];

      for (const p of patterns) {
          if (p.match.test(raw)) return p.msg;
      }

      // Fallback: take first 3 meaningful lines, truncate to 200 chars
      const fallback = raw.split('\n').filter(Boolean).slice(0, 3).join('；');
      return fallback.slice(0, 200) || '未知错误，请查看控制台日志';
  }

  _processSetupOutput(text) {
      // Parse setup stdout for step markers and update status area.
      // Called on every data event from child.stdout.
      const lines = text.split('\n').filter(Boolean);
      for (const line of lines) {
          // Match step lines like "[*] Phase 1: Pre-flight checks..."
          // or "[OK] ..." and "[FAIL] ..." markers from headless_setup
          if (line.includes('[*]') || line.includes('[OK]') || line.includes('[FAIL]')) {
              // Clean marker prefix for display
              const clean = line.replace(/^\[\*\].*\d+:?\s*/, '').replace(/^\[OK\]\s*/, '').replace(/^\[FAIL\]\s*/, '');
              this._setStatus(clean, 'progress');
          }
      }
  }

  _setStatus(message, type) {
      if (this._statusArea) {
          this._statusArea.setText(message);
          // Reset className then add type-specific class
          this._statusArea.className = 'paperforge-install-status';
          if (type) {
              this._statusArea.addClass(`paperforge-install-${type}`);
          }
      }
  }
  ```

  **Key design decisions:**

  **`_showNotice()`**: Uses Obsidian `Notice` API (already imported at line 1). Error notices get 8s duration (vs 4s for success) for readability. Prefix `[OK]/[!!]/[...]` provides quick visual scanning context.

  **`_formatSetupError()`**: 
  - Maps 5 common error patterns to Chinese messages
  - Patterns match against the `err.message` string (which in Node's `child_process` contains both the error code and any stderr content)
  - Fallback: first 3 lines joined with Chinese semicolon (；), capped at 200 chars
  - The full error is always available via `console.error()` in `_runSetup()`'s catch block
  
  **`_processSetupOutput()`**: 
  - Parses `headless_setup()` stdout line-by-line
  - Matches lines containing `[*]` (phase markers like "[*] Phase 2: Creating directories..."), `[OK]`, or `[FAIL]`
  - Strips the marker prefix for clean display
  - Updates the status area with color-coded progress style
  
  **`_setStatus()`**: 
  - Sets status div text and applies type-specific CSS class (success/error/progress)
  - Resets className first to remove previous type styling
  - The `_statusArea` div was created in Plan 01, so `this._statusArea` must exist when this is called

  Per STATE.md concern: "Raw stderr must be parsed into friendly Chinese messages before Notice display" — this is exactly what `_formatSetupError()` does. Raw stderr goes to console.error, mapped messages go to Notice.
  </action>

  <acceptance_criteria>
  - grep -n "_showNotice" paperforge/plugin/main.js returns 2+ matches (definition + usage)
  - grep -n "_formatSetupError" paperforge/plugin/main.js returns 2+ matches (definition + usage)
  - grep -n "_processSetupOutput" paperforge/plugin/main.js returns 2+ matches (definition + usage)
  - grep -n "_setStatus" paperforge/plugin/main.js returns 3+ matches (definition + usage in _runSetup + usage in _processSetupOutput)
  - grep -n "command not found" paperforge/plugin/main.js returns 1 match (inside _formatSetupError regex)
  - grep -n "Python 环境" paperforge/plugin/main.js returns 1 match (Chinese message)
  - grep -n "ModuleNotFoundError" paperforge/plugin/main.js returns 1 match (inside _formatSetupError regex)
  - grep -n "未知错误" paperforge/plugin/main.js returns 1 match (fallback message)
  - grep -n "addClass" paperforge/plugin/main.js returns 1 match (inside _setStatus)
  - The total added lines for 4 helpers should be ~50-70 lines
  </acceptance_criteria>

  <verify>
  <automated>python -c "
with open('paperforge/plugin/main.js') as f:
    content = f.read()
    checks = [
        ('_showNotice defined', '_showNotice' in content),
        ('_formatSetupError defined', '_formatSetupError' in content),
        ('_processSetupOutput defined', '_processSetupOutput' in content),
        ('_setStatus defined', '_setStatus' in content),
        ('5 error patterns in formatSetupError', len(content.split('_formatSetupError')[1].split('_processSetupOutput')[0].split('match:')) >= 5 if '_formatSetupError' in content and '_processSetupOutput' in content else False),
        ('Chinese fallback message', '未知错误' in content),
        ('addClass usage', 'addClass' in content),
        ('console.error exists', 'console.error' in content),
        ('new Notice called', 'new Notice' in content),
    ]
    for name, ok in checks:
        print(f'  [{\"OK\" if ok else \"FAIL\"}] {name}')
    assert all(ok for _, ok in checks), 'Some checks failed'
    print('ALL CHECKS PASSED')
"</automated>
  </verify>

  <done>
  Four helper methods added to `PaperForgeSettingTab`:
  - `_showNotice(type, title, detail)` — shows Obsidian toast with status prefix and appropriate duration
  - `_formatSetupError(raw)` — maps 5 common error patterns to Chinese, with fallback truncation
  - `_processSetupOutput(text)` — parses headless_setup stdout for step markers, updates status area
  - `_setStatus(message, type)` — updates status area div with color-coded CSS class
  </done>
</task>

</tasks>

<verification>
```bash
# 1. Verify all 5 methods exist
python -c "
with open('paperforge/plugin/main.js') as f:
    c = f.read()
    methods = ['_runSetup', '_showNotice', '_formatSetupError', '_processSetupOutput', '_setStatus']
    for m in methods:
        assert m in c, f'Missing method: {m}'
    assert '--headless' in c and '--non-interactive' not in c, 'Wrong flag name'
    assert 'PaperForgeStatusView' in c, 'Sidebar code removed'
    assert 'paperforge-sync' in c, 'Action definition removed'
    print('[OK] All 5 methods present, no regression')
"

# 2. Verify no raw error message patterns in Notice calls (INST-02 compliance)
python -c "
with open('paperforge/plugin/main.js') as f:
    c = f.read()
    # Check that no Notice call directly embeds stderr/err.message
    notice_calls = [l for l in c.split('\n') if 'new Notice' in l]
    for call in notice_calls:
        if 'err.message' in call or 'stderr' in call:
            print(f'[WARN] Possible raw error exposure: {call.strip()}')
    # The only new Notice calls should be in _showNotice and _formatSetupError
    print('[OK] Notice calls checked for INST-02 compliance')
"

# 3. Verify INST-04: sidebar and commands unchanged
python -c "
with open('paperforge/plugin/main.js') as f:
    c = f.read()
    assert 'class PaperForgeStatusView extends ItemView' in c, 'Sidebar class modified'
    assert 'class PaperForgePlugin extends Plugin' in c, 'Plugin class modified'
    assert 'addCommand' in c, 'Commands removed'
    assert 'addRibbonIcon' in c, 'Ribbon icon removed'
    print('[OK] INST-04: Existing sidebar and commands preserved')
"
```
</verification>

<success_criteria>
- [ ] `PaperForgeSettingTab._runSetup()` spawns `python -m paperforge setup --headless` with explicit args
- [ ] Button disabled during execution, re-enabled in `finally`
- [ ] `_showNotice()` renders success/error messages via Obsidian Notice API
- [ ] `_formatSetupError()` maps 5+ error patterns to Chinese messages, never raw traceback
- [ ] `_processSetupOutput()` parses stdout step markers from headless_setup
- [ ] `_setStatus()` updates status area with color-coded CSS class (success/error/progress)
- [ ] INST-04 verified: sidebar (`PaperForgeStatusView`) and commands (`addCommand`) unchanged
- [ ] `--non-interactive` is NOT used anywhere (correct flag is `--headless`)
- [ ] All automated verification checks pass
</success_criteria>

<output>
After completion, create `.planning/phases/21-one-click-install-and-polished-ux/21-02-SUMMARY.md`
</output>
