# Plan: binary 自管 install/uninstall（v0.8 MVP）

## Context

当前 `install.sh` / `uninstall.sh` / `install.ps1` / `uninstall.ps1` 四个 shell
脚本各自维护一份"如何注册到 Claude/Codex + 如何清缓存"的逻辑。已暴露的工程
代价：

- **缓存路径漂移风险**：`src/download.rs:156-159` 用 `dirs::data_local_dir()`
  写缓存，shell 脚本要在三平台手工镜像这套路径。已踩过 Codex 报的 P2 cygpath
  bug（Git Bash 下 `LOCALAPPDATA` 是 Windows 形式，POSIX 工具不认）。
- **CLI 调用四地维护**：`claude/codex mcp add/remove` 在 `install.sh:87-142`、
  `install.ps1:87-153`、`uninstall.sh:69-114`、`uninstall.ps1:35-131` 各有一份。
  改一处要改四处。
- **Claude `mcp remove` exit-code wart**：未注册和真错都返非零，shell 里只能
  捕获 stderr 让用户判。Rust 里要把这件事**真正做对**：先 `mcp list` probe，
  再决定 no-op / remove / add，把"未注册"和"真实配置/CLI 错误"分开，而不是
  只是把 shell 里的 `remove || true` 搬家。

把这套逻辑收进 binary 自身，让 `dirs::data_local_dir()` 是 source of truth、
让 shell 仅保留分发 + 平台黏合，业务逻辑全归 Rust。

## Non-goals (v0.8)

- **binary 自删**：Windows 文件锁 + Unix unlink-while-open 都不可靠，仍由
  shell 脚本在调完 `binary uninstall` 之后删。
- **PATH 操作**：仍由 shell（Windows 写 HKCU\Environment、Linux/macOS 仅
  打印提示）。Rust 不碰 registry。
- **下载 / 平台检测 / 文件锁 mv-aside**：仍由 shell。Rust 子命令假设 binary
  已就位。
- **不保留完整 shell fallback**：wrapper 不再在 shell 里重写一套 scope-aware
  deregister + cache 路径逻辑。仅两类分支例外：
  (1) **确认是 v0.7 老 binary** 时，打印手工清理指引并允许删 binary；
  (2) **binary 已缺失** 时，做 best-effort 的 CLI deregister 提示/尝试，但 cache
  仍只给手工命令，不在 shell 里重写 `dirs::data_local_dir()` 规则。
  **如果 binary 存在但 self-uninstall 失败，wrapper 必须保留 binary 供 retry，
  不能继续删。**
- **`cargo install perfetto-mcp-rs` 当主入口**：`build.rs:4` 当前依赖系统
  `protoc`，不是"零前置"。README/CHANGELOG 里明确标记为"开发者备援通道"，
  待 vendored protoc 或提交生成代码后再升格。
- **Plugin marketplace**：等 Codex 也有对等生态再说。
- **并发/race 特殊处理**：不为"两个终端同时 install"或"server 运行时另一端
  uninstall"加额外锁；假定用户不会主动打架。已知语义是 last-write-wins /
  临时不可用后按现有 download self-heal 恢复。

## 设计

### 新增 `src/install.rs`

**Crate 边界**：项目已有 `src/lib.rs`（`download` / `server` 等都在 lib
crate），`main.rs` 是消费 lib 的 binary crate。`install.rs` 必须放在 **lib
crate** 一侧（`pub mod install;` 加入 `lib.rs`），`crate::download::cache_root`
才能在同一 crate 内可见。如果放在 binary crate 一侧，`pub(crate)` 会跨不过
lib/bin 边界编译失败。`main.rs` 通过 `perfetto_mcp_rs::install::run_install`
调用。

**子进程库选型**：用 `std::process::Command`（同步）—— 不用 `tokio::process::Command`。
Server 路径走 tokio 是因为要协作 IO；install/uninstall 是一次性 CLI，不需要
runtime。`run_install` / `run_uninstall` 是同步 fn，被 `#[tokio::main]` 包裹
无害（runtime 启动开销 < 1ms，CLI 总耗时几百 ms 量级）。

公开 API（被 `main.rs` 调用）：

```rust
pub fn run_install(args: InstallArgs) -> anyhow::Result<()>;
pub fn run_uninstall(args: UninstallArgs) -> anyhow::Result<()>;

#[derive(clap::Args, Debug)]
pub struct InstallArgs {
    /// Path to register with Claude/Codex. If omitted, falls back to
    /// `std::env::current_exe()` — but on Linux that resolves symlinks
    /// (`/proc/self/exe` is already the target), so the install scripts
    /// MUST pass this explicitly with `${INSTALL_DIR}/${bin_file}` to
    /// preserve the symlink-form path users see at $INSTALL_DIR.
    #[arg(long)]
    pub binary_path: Option<PathBuf>,
    /// Claude scope (user/local/project). Ignored by Codex (no scope concept).
    /// For --scope local / project, run from the target project directory.
    /// `local` is stored in `~/.claude.json` but keyed by the current project
    /// path; `project` uses `.mcp.json` in that directory.
    #[arg(long, value_enum, default_value_t = ClaudeScope::User)]
    pub scope: ClaudeScope,
    #[arg(long)]
    pub skip_claude: bool,
    #[arg(long)]
    pub skip_codex: bool,
}

#[derive(clap::Args, Debug)]
pub struct UninstallArgs {
    /// Claude scope used at install time. Must match — `claude mcp remove`
    /// is scope-keyed; if you installed with `--scope local`, pass the same
    /// here or the registration will be left behind under that scope.
    /// **For --scope local / project**, this command MUST be run from the
    /// original project directory. `local` is keyed by CWD inside
    /// `~/.claude.json`; `project` uses that directory's `.mcp.json`.
    #[arg(long, value_enum, default_value_t = ClaudeScope::User)]
    pub scope: ClaudeScope,
    #[arg(long)]
    pub keep_cache: bool,
    #[arg(long)]
    pub skip_claude: bool,
    #[arg(long)]
    pub skip_codex: bool,
}

#[derive(clap::ValueEnum, Clone, Copy, Debug)]
pub enum ClaudeScope { User, Local, Project }
```

`SERVER_NAME = "perfetto-mcp-rs"` 作模块顶层 `const &str`（一处真相），不
搞 spec 抽象 —— 单一服务下的 spec struct 是过度设计。

私有 helper（顶层 fn，参数明示）：

- `fn resolve_binary_path(arg: Option<PathBuf>) -> anyhow::Result<PathBuf>`
  — 优先用 `--binary-path` 显式参数（shell wrapper 必传），fallback 到
  `std::env::current_exe()`。**不** `canonicalize`。
  - 关键事实：`current_exe()` 在 Linux 上读 `/proc/self/exe`，**已经是
    resolve 过 symlink 的真路径**，与"保留 symlink"不一致。要真正与
    install.sh 行为对齐，shell 必须显式传 `${INSTALL_DIR}/${bin_file}`，
    不能让 binary 自己问。
  - fallback 留给"用户手跑 binary install"的边角场景；接受 symlink 解析
    后的路径（这种情况下用户期望也是当前进程实际位置）。
  - **Windows Git Bash/MSYS/Cygwin**：wrapper 传进来的 `--binary-path` 必须先
    由 `cygpath -m` 转成 Windows 形式（`C:/Users/...`），不能把 `/c/...`
    原样交给 Rust 再注册给 `claude` / `codex`。
  - 不 canonicalize：避开 Windows `\\?\` UNC 前缀引发的下游兼容麻烦，免
    引入 `dunce` dep。
- `fn detect_cli(name: &str) -> bool` — 用 `which::which`（`Cargo.toml:21` 已有）。
  在每个 register/deregister 入口先调，false → `Outcome::Skipped`。
- `fn is_registered_with_claude() -> anyhow::Result<bool>` — 调 `claude mcp list`
  并解析当前可见配置里是否已有 `SERVER_NAME`。
  - **事实约束**：本机 `claude mcp list --help` 没有 `--scope` 选项；`local` /
    `project` 的可见性依赖当前工作目录，这正是上面要求用户从原项目目录运行
    install/uninstall 的原因。
  - 只以 process exit status 判成功/失败；成功时只解析 `stdout`，不要因为
    `stderr` 有 warning/noise 就误判失败。
  - `list` 自己非零是**真失败**，不能再像 shell 一样吞掉。
- `fn is_registered_with_codex() -> anyhow::Result<bool>` — 调 `codex mcp list`
  并解析是否已有 `SERVER_NAME`。同样，`list` 非零就是 `Failed`。
  - 同样只解析 `stdout`；成功时的 `stderr` warning 不是失败信号。
- **不做 config-file existence preflight**：`local` scope 不在 `./.claude.json`
  而是在 `~/.claude.json` 中按当前项目路径索引；`project` scope 首次 install
  时 `.mcp.json` 可能本来就不存在、由 `claude mcp add` 创建。因此 install /
  uninstall **不能**靠 `Test-Path .claude.json` / `std::fs::metadata(".mcp.json")`
  这类检查来判定 scope 是否可用。实现上只在报错信息里附加 context：
  如果 `scope != User`，提示"请从原 install/uninstall 的项目目录重试"。
- `fn register_claude(bin: &Path, scope: ClaudeScope) -> Outcome`
  - 先 `is_registered_with_claude()`。已注册 → `remove` 再 `add`；未注册 →
    **直接 `add`**。这是 v0.8 真正解决"first install 时 `remove` 非零" wart 的
    方式；不要再写一版 `remove || true`。
  - 因为有 pre-probe，`remove` 一旦被调用却返非零，就应视为真失败而不是
    "也许本来没注册"。
  - 当 `scope != User` 且 `claude mcp add` 返非零时，不只返回原始 stderr。
    在 `Outcome::Failed(...)` 里前置一句固定 hint：
    `"scope=<local|project>: this command must run from the project directory \
    that was used at install time. If you're sure this is the right directory, \
    the underlying error follows:\n"`，后面再拼原始 stderr。
- `fn register_codex(bin: &Path) -> Outcome` — Codex 无 scope，`scope` 参数
  在调用方根本不传给它。
  - 同样先 `is_registered_with_codex()`；已注册才 `remove` 再 `add`，未注册则
    直 `add`。与 Claude 逻辑保持对称。
- `fn deregister_claude(scope: ClaudeScope) -> Outcome` —
  Claude 的 `mcp remove --scope <X>` 必须带 scope（实测：缺 scope 会报错）。
  - 先 `is_registered_with_claude()`。未注册 → `Outcome::Skipped("not currently registered")`；
    已注册才真 `remove`。这样 `uninstall` 才能把幂等 no-op 和真错分开。
  - 当 `scope != User` 且 `claude mcp remove` 返非零时，用与
    `register_claude` 同款的前置 hint 包住原始 stderr；不要把这条 hint
    挂到 Codex 路径，Codex 没有 scope/CWD 语义。
- `fn deregister_codex() -> Outcome`
  - 先 `is_registered_with_codex()`；未注册直接 `Skipped`，已注册时 `remove`
    非零才算真失败。
- `fn clean_cache() -> Outcome` — 直接调 `crate::download::cache_root()`，
  `std::fs::remove_dir_all` 忽略 `NotFound`。
  - `cache_root()` 若因 `HOME` / `XDG_DATA_HOME` / `LOCALAPPDATA` 不可用而失败，
    视为 `Outcome::Skipped("cache root unavailable; skipping cleanup")`，**不是**
    `Failed`。要保持与现有 shell uninstall 一样的"无法判定 cache 位置 = no-op"
    语义。
  - 只有 `remove_dir_all` 对一个已确定的路径返出 `NotFound` 之外的错误，才算
    真 `Failed`。

`enum Outcome { Done(String), Skipped(String), Failed(String) }` — 各分支
持一段 user-facing message。

**Exit code 聚合规则**（明确写死）：
- 任何一个步骤是 `Failed` → `run_install` / `run_uninstall` 返
  `Err(anyhow!(...))`，process 退出非零。
- 全 `Done` 或 `Done + Skipped` 混合 → 返 `Ok(())`，exit 0。
- 这影响 shell wrapper 的 `if binary install` 分支：partial failure 走 else 分支。

### 修改 `src/main.rs`

加 clap `Subcommand`，**默认无子命令 = 当前 MCP server 行为**（向后兼容）：

```rust
#[derive(Parser)]
struct Cli {
    #[command(subcommand)]
    command: Option<Command>,
    // 现有 server flags 保留
    #[arg(long, default_value_t = 3)]
    max_instances: usize,
    // ...
}

#[derive(Subcommand)]
enum Command {
    /// Register this binary with Claude Code and Codex.
    Install(InstallArgs),
    /// Deregister + clean cache. Does NOT remove the binary itself.
    Uninstall(UninstallArgs),
}

// install 在 lib crate 一侧（src/lib.rs 的 `pub mod install;`），
// binary crate 通过 lib 名引入。`crate::install::...` 在这里指 binary
// crate，找不到 install，必须走 perfetto_mcp_rs::install。
use perfetto_mcp_rs::install;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let mut cli = Cli::parse();
    // .take() 替换为 None 而不 move 出 cli，让 None 分支仍能传 cli 给 run_server。
    // 直接 `match cli.command { ... None => run_server(cli) }` 会触发 partial-move 编译错。
    match cli.command.take() {
        Some(Command::Install(a))   => install::run_install(a),
        Some(Command::Uninstall(a)) => install::run_uninstall(a),
        None => run_server(cli).await,  // 现有逻辑封装
    }
}
```

install/uninstall 是同步代码（用 `std::process::Command`，详见上方），但
被 `#[tokio::main]` 包裹无害。

### 修改 `src/download.rs`

新增 `pub(crate) fn cache_root() -> anyhow::Result<PathBuf>`，返回 unversioned
父目录 `<base>/perfetto-mcp-rs`（卸载用，覆盖所有 TP_VERSION 子目录）。
现有私有 `fn cache_dir()`（line 156-159，返回 `<base>/perfetto-mcp-rs/<TP_VERSION>`）
内部改成 `cache_root()?.join(TP_VERSION)`，对外可见性保持不变。

```rust
pub(crate) fn cache_root() -> anyhow::Result<PathBuf> {
    let base = dirs::data_local_dir()
        .context("cannot determine local data directory")?;
    Ok(base.join("perfetto-mcp-rs"))
}

fn cache_dir() -> anyhow::Result<PathBuf> {
    Ok(cache_root()?.join(TP_VERSION))
}
```

`install.rs` 直接调 `crate::download::cache_root()`，不在 install.rs 里再
定义本地版本。

### 修改 shell 脚本（v0.8 落地范围）

**注意 `$platform` 命名两脚本不一致**：install.sh 的 `detect_platform` 返
`linux-amd64` / `darwin-amd64` / `windows-amd64`（带 arch 后缀给 release URL
用），uninstall.sh 的 `detect_platform` 返 `linux` / `macos` / `windows`
（不带后缀）。**各自沿用现有约定**，下面两段 snippet 的 `case` 模式不可
互换：install.sh 用 `windows-*)`，uninstall.sh 用 `windows)`。

`install.sh` 的 `register_with_claude` (87-114) + `register_with_codex`
(116-142) 删除，替换为：

```sh
: "${SCOPE:=user}"  # SCOPE env var 透传给 binary，默认 user
register_path="${INSTALL_DIR}/${bin_file}"

# Windows POSIX shells 下 INSTALL_DIR 常是 /c/Users/...；Claude/Codex CLI
# 需要 Windows-form 路径（C:/Users/...），所以 wrapper 先转好再交给 Rust。
case "$platform" in
  windows-*)
    if command -v cygpath >/dev/null 2>&1; then
      register_path="$(cygpath -m "$register_path")"
    fi
    ;;
esac

# 显式传 --binary-path，让 binary 注册"我们装到的路径"而不是
# current_exe() 在 Linux 上 resolve 出来的真路径（避免 symlink 升级时
# 注册指向旧目标）。
if "${INSTALL_DIR}/${bin_file}" install \
     --scope "$SCOPE" \
     --binary-path "$register_path"; then
  info "Self-registered with available CLIs."
else
  warn "binary self-install reported issues; check output above."
fi
```

`uninstall.sh` 的 `deregister_from_claude/codex` + cache_dir + 缓存清理段
全部删除，替换为：

```sh
: "${SCOPE:=user}"  # local/project 时必须从原项目目录运行
removable=no        # 仅在 binary uninstall 成功 / 已不在 / 确认 v0.7 时才删 binary
bin_file="${BIN_NAME}"   # set -u 下必须先初始化，再拼 INSTALL_DIR/bin_file

case "$platform" in
  windows) bin_file="${BIN_NAME}.exe" ;;
esac

if [ -e "${INSTALL_DIR}/${bin_file}" ]; then
  help_status=0
  help_output="$("${INSTALL_DIR}/${bin_file}" --help 2>&1)" || help_status=$?

  # v0.7 检测：grep top-level `--help` 找 `uninstall` 子命令名。
  # **不要**用 `uninstall --help`：clap 的 --help 是 short-circuit arg，
  # v0.7 binary 上 `uninstall --help` 会先打印 v0.7 自己的 help 然后 exit
  # 0（"uninstall" 当成了未校验的 positional），让我们误判为 v0.8。
  # `--help` 输出里的子命令缩进列表是稳定 grep 目标。
  # 注意 `grep -E` 不支持 `\s`；必须用 POSIX `[[:space:]]`。
  if [ "$help_status" -eq 0 ]; then
    if printf '%s\n' "$help_output" | grep -qE '^[[:space:]]+uninstall([[:space:]]|$)'; then
      if "${INSTALL_DIR}/${bin_file}" uninstall --scope "$SCOPE"; then
        removable=yes
      else
        warn "binary uninstall failed under --scope ${SCOPE}; **keeping binary in place**"
        warn "for retry. Common causes: locked cache dir, or --scope local/project run"
        warn "from the wrong directory. Fix the issue and re-run uninstall.sh."
        # removable 留 no
      fi
    else
      warn "old binary (v0.7) without 'uninstall' subcommand; manual cleanup needed:"
      warn "  claude mcp remove perfetto-mcp-rs --scope ${SCOPE}"
      warn "    # for --scope local/project, run that from the original project dir"
      warn "  codex  mcp remove perfetto-mcp-rs"
      warn "  (cache: see CHANGELOG Migration section for 3-platform commands)"
      removable=yes  # 确认是 v0.7 时，允许继续删 binary
    fi
  else
    warn "installed binary exists but could not start; **keeping it in place**"
    warn "for inspection/retry instead of misclassifying it as v0.7."
    printf '%s\n' "$help_output" | sed 's/^/    /' >&2
    # removable 留 no
  fi
else
  # Recovery：binary 已被手工删/挪走时的兜底。**仅** deregister，cache
  # 清理交给手工命令（避免在 shell 里再镜像 dirs::data_local_dir 知识）。
  warn "binary missing at ${INSTALL_DIR}/${bin_file}; running shell fallback for deregistration."
  # 透传用户选的 scope；对 local/project，若当前不在原项目目录则 remove 会失败，
  # 但这仍比硬编码成 user 更接近用户意图。
  command -v claude >/dev/null 2>&1 \
    && claude mcp remove perfetto-mcp-rs --scope "$SCOPE" 2>&1 | sed 's/^/    /'
  command -v codex >/dev/null 2>&1 \
    && codex mcp remove perfetto-mcp-rs 2>&1 | sed 's/^/    /'
  warn "If you installed with --scope local or --scope project, run from that project dir:"
  warn "  claude mcp remove perfetto-mcp-rs --scope ${SCOPE}"
  warn "Cache not auto-cleaned (binary missing). Remove manually if you want it gone:"
  warn "  Linux:   rm -rf \"\${XDG_DATA_HOME:-\$HOME/.local/share}/perfetto-mcp-rs\""
  warn "  macOS:   rm -rf \"\$HOME/Library/Application Support/perfetto-mcp-rs\""
  warn "  Windows (Git Bash): rm -rf \"\$(cygpath -u \"\$LOCALAPPDATA\")/perfetto-mcp-rs\""
  warn "  Windows (PowerShell): Remove-Item -Recurse -Force \"\$env:LOCALAPPDATA\\perfetto-mcp-rs\""
  removable=yes  # 已经不在了
fi

# 仅在确认 binary 该删时才动手 —— 失败保留是 retry-able 状态的关键。
if [ "$removable" = "yes" ]; then
  # Windows 上 .exe 被占用时 unlink 失败，复用 install.sh:177-186 的 mv-aside。
  case "$platform" in
    windows)
      if [ -e "${INSTALL_DIR}/${bin_file}" ]; then
        ts="$(date +%s)"
        mv "${INSTALL_DIR}/${bin_file}" "${INSTALL_DIR}/${bin_file}.old-${ts}" 2>/dev/null \
          || warn "could not rename ${bin_file} aside (still locked? close MCP clients and re-run)"
        rm -f "${INSTALL_DIR}/${bin_file}.old-${ts}" 2>/dev/null || true
      fi
      ;;
    *)
      rm -f "${INSTALL_DIR}/${bin_file}"
      ;;
  esac
fi
```

`install.ps1` / `uninstall.ps1` 对应：PowerShell install 端继续复用现有
`$clientPath`（正斜杠 Windows 路径）并把它透传给 `install --binary-path`；
missing-binary recovery 同样处理；PowerShell 端 mv-aside 复用
`install.ps1:55-70` 现有模式。

### 向后兼容

- **新 install.sh + 老 binary（v0.7）**：流程是 "下载新 binary → 调 install"，
  到 install 时 binary 已是新的，不会出问题。✓
- **新 uninstall.sh + 老 binary（v0.7）**：v0.7 没有 `uninstall` 子命令，
  top-level `--help` 中也没有 `uninstall` 子命令。wrapper 仅在**确认是这个
  分支**时打印手工清理指引并继续删 binary；如果 binary 连 `--help` 都跑不起来，
  则**不**把它误判成 v0.7，而是保留文件供 retry/诊断。CHANGELOG 明确：
  "想干净卸载请先重跑 install.sh 升到 v0.8+，再跑 uninstall.sh"。残留：
  v0.7 的注册条目和缓存可能留下，用户可手工执行对应 scope 的
  `claude mcp remove perfetto-mcp-rs --scope <user|local|project>` /
  `codex mcp remove perfetto-mcp-rs` / 三平台 cache 清理命令（见下方
  CHANGELOG 草稿 Migration 段）。

## 要修改/新增的文件

| 文件 | 改动 |
|---|---|
| `src/install.rs` | **新增** — install/uninstall 子命令的 Rust 实现，~200 行。**放在 lib crate**（与 `download.rs` 同侧），见下方 crate boundary 说明 |
| `src/lib.rs` | 加一行 `pub mod install;`，让新模块在 lib crate 内注册 |
| `src/main.rs` | clap `Subcommand` enum + dispatch，~15 行新增；调用走 `perfetto_mcp_rs::install::run_install(...)` |
| `src/download.rs` | 新增 `pub(crate) fn cache_root()` 暴露给 install.rs；现有 `cache_dir()` 内部改成 `cache_root()?.join(TP_VERSION)`，可见性不变 |
| `install.sh` | 删 register_with_* 两段（~55 行），加 5 行调用 binary |
| `uninstall.sh` | 删 register-side deregister 主逻辑（~50 行）+ 删 cache_dir 函数（~25 行，Rust 单源后不再需要），加 binary 调用 + missing-binary recovery 兜底（仅 deregister，cache 走手工提示）+ Windows mv-aside 删 binary |
| `install.ps1` | 删 PowerShell register 段，加 binary 调用 |
| `uninstall.ps1` | 删 PowerShell deregister + cache 段，加 binary 调用 + mv-aside |
| `Cargo.toml` | 预计**无需改动**：现有 `clap` features=`["derive","env"]` 已足够支持 subcommand |
| `CHANGELOG.md` | 新增 v0.8.0 区块（draft 见下方"CHANGELOG 草稿"段） |
| `README.md` / `README.zh-CN.md` | 补 5-10 行：记录 `SCOPE=user|local|project` 的脚本入口，以及"已有 binary 时可直接跑 `perfetto-mcp-rs install/uninstall`" |

净代码变化预估：Rust +~220 行，shell -~150 行，README +~10 行。

## CHANGELOG 草稿

实施时按项目现有 `CHANGELOG.md` 的 heading 风格 / 措辞模板调整 —— 下面只
是内容骨架，格式听 repo 现行约定。

```markdown
## 0.8.0

### Added
- `perfetto-mcp-rs install` and `perfetto-mcp-rs uninstall` subcommands.
  The binary now self-registers with Claude Code (`claude mcp add`) and
  Codex (`codex mcp add`), and self-cleans its `dirs::data_local_dir()`
  cache. Default subcommand-less invocation continues to run the MCP
  server (no behavior change for existing MCP clients).
- `--scope user|local|project` flag on both `install` and `uninstall`
  for Claude scope selection (Codex has no scope concept; ignored there).
  For `--scope local` / `project`, run from the target/original project
  directory.
- `install --binary-path <PATH>` flag for the install scripts to register
  a specific path (typically `${INSTALL_DIR}/${bin_file}`) rather than
  letting the binary infer it from `current_exe()` (which on Linux
  resolves symlinks).

### Changed
- `install.sh` / `install.ps1` / `uninstall.sh` / `uninstall.ps1` now
  delegate registration and cache cleanup to the binary subcommands.
  Shell scripts keep distribution/platform glue only: download/install,
  PATH hints, path-format conversion, Windows file-lock handling, and
  final binary removal.
- `SCOPE` env var on install/uninstall scripts (default `user`) plumbs
  through to `--scope`. For local/project installs, run uninstall from
  the same project directory: `SCOPE=local bash uninstall.sh`.
- Uninstall scripts **preserve the binary on subcommand failure** so the
  user can fix the issue (locked cache, wrong project dir, etc.) and
  re-run. Only successful uninstall (or confirmed v0.7 "no subcommand"
  case, or already-missing binary) deletes the binary.

### Migration
- **Upgrading from 0.7.x → 0.8.0**: re-run `install.sh` (or `install.ps1`).
  The new binary brings the subcommands; subsequent `uninstall.sh` will
  use them automatically.
- **Uninstalling a 0.7.x install with the new uninstall.sh**: the script
  will warn and remove the binary, but the Claude/Codex registration and
  cache may remain. Upgrade first (`install.sh`), then uninstall, for a
  clean wipe. Or clean up by hand:
  ```sh
  claude mcp remove perfetto-mcp-rs --scope user
  codex  mcp remove perfetto-mcp-rs
  # Cache (pick the one for your OS):
  rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/perfetto-mcp-rs"   # Linux
  rm -rf "$HOME/Library/Application Support/perfetto-mcp-rs"      # macOS
  # Windows Git Bash/MSYS:  $LOCALAPPDATA is a native path (C:\...), POSIX
  # rm can't walk it. Convert with cygpath, or use PowerShell:
  rm -rf "$(cygpath -u "$LOCALAPPDATA")/perfetto-mcp-rs"          # Windows (Git Bash)
  # PowerShell: Remove-Item -Recurse -Force "$env:LOCALAPPDATA\perfetto-mcp-rs"
  ```

### Notes
- `cargo install perfetto-mcp-rs` remains a developer-fallback channel
  only — it requires a system `protoc` (see `build.rs`). End users should
  use `install.sh` / `install.ps1`.
```

## 复用现有

- `dirs::data_local_dir()` via `crate::download::cache_root()` — 新暴露
  in `src/download.rs`
- `which::which` for CLI 探测 — `Cargo.toml:21` 已依赖
- `std::process::Command` 调用 claude/codex（**注意**：tp_manager 用的是
  `tokio::process::Command`，install 路径不需要 async，用 std 版即可，无新依赖）
- `anyhow::Context::with_context` 错误链 — 全 codebase 一致
- `tracing::info!` / `tracing::warn!` 已在用，但子命令是一次性 CLI 输出，
  直接 `println!` 比走 tracing 更简洁。决定：**子命令用 println，不接入
  tracing-subscriber**（避免触发 server 的 logging 配置）。

## 验证

### 集成测试（fake-binary 路线）

光跑 `--skip-claude --skip-codex` 等于只测 clap parser，不覆盖核心
register/deregister。改用 **fake-binary fixtures** 真测调用契约：

`tests/fixtures/claude` / `tests/fixtures/codex`（**关键**：文件名必须
是 `claude` / `codex` 不带后缀 —— 测试设 `PATH=<fixtures>` 后，`which::which("claude")`
按字面名查找，`fake_claude.sh` 这种名字找不到。**commit 时确认 file mode
0755**：`git ls-files --stage tests/fixtures` 应见 `100755`，否则 CI 上 exec
位丢失测试 silent fail）：
```sh
#!/bin/sh
# 只能用 shell builtins：测试会把 PATH 设成仅 fixtures 目录，不能依赖
# /usr/bin/basename / grep / sed 之类外部命令。
tool="${0##*/}"
state_dir="${FAKE_STATE_DIR:-.}"
state_file="${state_dir}/${tool}.servers"
printf '%s|%s\n' "$tool" "$*" >> "${FAKE_RECORD_FILE:-/dev/null}"

case "$tool:$1:$2" in
  claude:mcp:list)
    stdout="${FAKE_CLAUDE_LIST_STDOUT:-}"
    exit_code="${FAKE_CLAUDE_LIST_EXIT_CODE:-0}"
    ;;
  claude:mcp:remove)
    stdout="${FAKE_CLAUDE_REMOVE_STDOUT:-}"
    exit_code="${FAKE_CLAUDE_REMOVE_EXIT_CODE:-0}"
    ;;
  claude:mcp:add)
    stdout="${FAKE_CLAUDE_ADD_STDOUT:-}"
    exit_code="${FAKE_CLAUDE_ADD_EXIT_CODE:-0}"
    ;;
  codex:mcp:list)
    stdout="${FAKE_CODEX_LIST_STDOUT:-}"
    exit_code="${FAKE_CODEX_LIST_EXIT_CODE:-0}"
    ;;
  codex:mcp:remove)
    stdout="${FAKE_CODEX_REMOVE_STDOUT:-}"
    exit_code="${FAKE_CODEX_REMOVE_EXIT_CODE:-0}"
    ;;
  codex:mcp:add)
    stdout="${FAKE_CODEX_ADD_STDOUT:-}"
    exit_code="${FAKE_CODEX_ADD_EXIT_CODE:-0}"
    ;;
  *)
    stdout=""
    exit_code=99
    ;;
esac

case "$1:$2" in
  mcp:list)
    if [ -s "$state_file" ]; then
      while IFS= read -r line; do
        printf '%s\n' "$line"
      done < "$state_file"
    fi
    [ -n "$stdout" ] && printf '%s\n' "$stdout"
    exit "$exit_code"
    ;;
  mcp:add)
    [ "$exit_code" -eq 0 ] && printf '%s\n' "perfetto-mcp-rs" > "$state_file"
    ;;
  mcp:remove)
    [ "$exit_code" -eq 0 ] && : > "$state_file"
    ;;
esac

[ -n "$stdout" ] && printf '%s\n' "$stdout"
exit "$exit_code"
```

Windows 端 `tests/fixtures/claude.cmd` / `codex.cmd` 镜像同语义（`.cmd`
后缀必需，Windows 才会作 batch 执行；`which::which("claude")` 在 Windows
上自动尝试 `PATHEXT` 列出的扩展名包括 `.cmd`）。

`tests/install_subcommand.rs`：
```rust
#[test]
fn install_invokes_claude_and_codex_with_expected_args() {
    let dir = tempfile::tempdir().unwrap();
    let record = dir.path().join("calls.txt");
    let registered = dir.path().join("registered-binary");
    // 用 cargo 内建 env var，不引 assert_cmd 等新 dev-dependency。
    let bin = env!("CARGO_BIN_EXE_perfetto-mcp-rs");

    // 用 only fixtures dir 作 PATH，**不**保留真 PATH —— 否则 which::which
    // 在 prepend 顺序异常 / 大小写不敏感文件系统上可能命中真 claude/codex，
    // 测试就不 hermetic 了。CARGO_MANIFEST_DIR 给绝对路径，跨工作目录稳定。
    let fixtures = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures");
    let output = Command::new(bin)
        .args([
            "install",
            "--scope", "user",
            "--binary-path", registered.to_str().unwrap(),
        ])
        .env("PATH", fixtures)
        .env("FAKE_RECORD_FILE", &record)
        .env("FAKE_STATE_DIR", dir.path())
        .output().unwrap();
    assert!(
        output.status.success(),
        "stderr={}",
        String::from_utf8_lossy(&output.stderr)
    );

    let calls: Vec<_> = fs::read_to_string(&record).unwrap()
        .lines()
        .map(str::to_owned)
        .collect();
    assert_eq!(
        calls,
        vec![
            "claude|mcp list".to_string(),
            format!(
                "claude|mcp add perfetto-mcp-rs --scope user {}",
                registered.display()
            ),
            "codex|mcp list".to_string(),
            format!(
                "codex|mcp add perfetto-mcp-rs -- {}",
                registered.display()
            ),
        ]
    );
}
```

要测的契约：
1. `install` 在 **list 为空** 时：先 `mcp list`，再直接 `add`；**不应**调用
   `remove`。随后再次调用 fake `claude mcp list` / `codex mcp list`，
   应能看到 `perfetto-mcp-rs`，证明"注册生效"而不只是"add 被调用过"。
2. `install` 在 **list 预置已有条目** 时：`list -> remove -> add` 顺序精确成立；
   Claude 带 `--scope`，Codex 用 `--`。
3. `install` 在 **list 预置已有条目** 且设 `FAKE_CLAUDE_REMOVE_EXIT_CODE=17`
   时：overall exit 必须非零，且 `claude add` **不应**再被调用。这条锁住
   "remove/add exit code 可独立控制" 与"existing entry 的 remove 失败是真错"。
4. `install --skip-codex`：codex fixture 没被调。
5. `install` 时设 `FAKE_CLAUDE_ADD_EXIT_CODE=17`、`FAKE_CLAUDE_ADD_STDOUT=boom`
   （Codex 保持 0）：overall exit 必须非零，stderr 必须保留 fake 输出；这条
   锁住"任一 Failed => 整体失败"的聚合规则。
6. `install --scope local` 时令 Claude add/remove 非零：stderr 必含固定的
   project-directory hint 前缀，防止后续重构把它删掉。
7. `uninstall` 默认路径：若 fake `list` 先显示已注册，则 `remove` 后再次
   `list` 应为空；同时用 tempdir 做 `XDG_DATA_HOME` 预置
   `<temp>/perfetto-mcp-rs/<TP_VERSION>/...`，跑默认 `uninstall` 后断言整个
   `perfetto-mcp-rs` 目录消失。
8. `uninstall --keep-cache`：同样预置 tempdir cache，但目录应保留。
9. `clean_cache` 在 cache root 不可判定时（例如 `HOME` / `XDG_DATA_HOME`
   清空）：exit 0，并给出 "skipping cleanup" 文案；不要让 wrapper 因此保留 binary。
10. CLI 缺失分支：`tempfile::tempdir()` 出空目录作 PATH（不能用 `PATH=""`，
   某些 shell 会回落到内建 default），跑 install/uninstall：exit 0、output
   含 "claude not found, skipping"。

CI 跑全套（fixtures 自带，零外部依赖）。

### 手工 e2e

```sh
cargo build --release

# 干净环境模拟首次安装
./target/release/perfetto-mcp-rs install --scope user
claude mcp list | grep perfetto-mcp-rs    # 应有
codex mcp list | grep perfetto-mcp-rs     # 应有

# 幂等再装一次
./target/release/perfetto-mcp-rs install --scope user
# 应无报错，注册仍在

# 卸载
./target/release/perfetto-mcp-rs uninstall
claude mcp list | grep perfetto-mcp-rs    # 应无
codex mcp list | grep perfetto-mcp-rs     # 应无

# 缓存路径检查（**按平台选**，对应 dirs::data_local_dir()）：
#   Linux:   ~/.local/share/perfetto-mcp-rs       （或 $XDG_DATA_HOME/perfetto-mcp-rs）
#   macOS:   ~/Library/Application Support/perfetto-mcp-rs
#   Windows: %LOCALAPPDATA%\perfetto-mcp-rs
ls "<上面对应当前平台的路径>" 2>/dev/null && echo "FAIL: cache survived" \
  || echo "cache cleaned"

# 缺 CLI 场景
PATH=/usr/bin ./target/release/perfetto-mcp-rs install
# 应打印 "claude not found, skipping" / "codex not found, skipping" 但 exit 0
```

### Shell 脚本回归

把改造后的 install.sh / uninstall.sh 跑一次端到端，确认与
`./target/release/perfetto-mcp-rs install/uninstall` 直接调用结果一致。

### CI

- `shellcheck install.sh uninstall.sh` —— 至少把新的 shell state machine
  过一遍静态检查；这部分逻辑比 v0.7 明显更复杂，不能只靠手工 e2e。
- `cargo fmt --check` + `clippy -D warnings` + `cargo test --all-targets`
  按 memory feedback 跑全。
- 新集成测试在不依赖 claude/codex 的子集上覆盖核心代码路径。

## 落地节奏建议

1. **PR 1**: `src/download.rs` 暴露 `cache_root()` + 新 `src/install.rs`
   **完整实现** + hermetic 集成测试 + `main.rs` subcommand wiring。由于
   `install/uninstall` 一旦接线就会出现在 `--help` 且成为公开命令，这一步
   不能 ship "仅 skeleton / 仅 skip-claude 生效" 的半成品。
2. **PR 2**: 改造 shell / PowerShell 脚本调用新子命令 + CHANGELOG。
   这一步是分发层行为变化点，单独 commit 便于回滚。

每个 PR 都跑全 CI gate。
