# 跨平台思维指南

> **目的**：在跨平台假设变成 bug 之前捕获它们。

---

## 为什么这很重要

**大多数跨平台 bug 来自于隐式假设**：

- 假设 shebang 可以工作 → Windows 上会失败
- 假设 `/` 路径分隔符 → Windows 上会失败
- 假设 `\n` 行尾 → 行为不一致
- 假设命令可用性 → `grep` vs `findstr`

---

## 平台差异检查清单

### 1. 脚本执行

| 假设 | macOS/Linux | Windows |
|------------|-------------|---------|
| Shebang（`#!/usr/bin/env python3`） | ✅ 可用 | ❌ 被忽略 |
| 直接执行（`./script.py`） | ✅ 可用 | ❌ 失败 |
| `python3` 命令 | ✅ 可用 | ⚠️ 可能需要 `python` |
| `python` 命令 | ⚠️ 可能是 Python 2 | ✅ 通常是 Python 3 |

**规则 1**：对于面向用户的文档、帮助文本和错误消息，要么：

- 明确说明平台规则（Windows 上用 `python`，其他平台用 `python3`），或
- 通过代码使用的相同平台感知 helper / 占位符呈现命令。

```python
# 不好 - 假设 shebang 可以工作
print("Usage: ./script.py <args>")
print("Run: script.py <args>")

# 好 - 平台感知的措辞
print("Usage: python on Windows, python3 elsewhere")
print("Run: {{PYTHON_CMD}} ./.constella/scripts/task.py <args>")
```

**规则 2**：在 init 时生成配置文件，使用占位符 + 平台检测：

```typescript
// 在模板文件中（settings.json）：
{ "command": "{{PYTHON_CMD}} .claude/hooks/script.py" }

// 在配置器中：
function getPythonCommand(): string {
  return process.platform === "win32" ? "python" : "python3";
}

function replacePlaceholders(content: string): string {
  return content.replace(/\{\{PYTHON_CMD\}\}/g, getPythonCommand());
}
```

**规则 3**：在运行时从 JavaScript 调用 Python 时，动态检测平台：

```javascript
import { platform } from "os"

const PYTHON_CMD = platform() === "win32" ? "python" : "python3"
execSync(`${PYTHON_CMD} "${scriptPath}"`, { ... })
```

**规则 4**：如果你需要验证 Python 确实已安装（而不仅仅是选择命令），探测你稍后将渲染或执行的相同平台选择别名：

```typescript
function getPythonCommand(platform = process.platform): string {
  return platform === "win32" ? "python" : "python3";
}

function warnIfPythonTooOld(): void {
  const cmd = getPythonCommand();
  try {
    execSync(`${cmd} --version`, { stdio: "pipe" });
  } catch {
    // 缺少 Python 是单独的错误的路径；不要静默交换别名。
  }
}
```

**规则 5**：不要假设 AI CLI 使用的 Python 版本与你的 shell 的 `python3` 匹配。用户的终端可能解析 `python3` → homebrew 3.11，但 AI CLI 主机（包括企业分叉的 Claude Code / Cursor 分发）用最小化 PATH 生成 hook 子进程，解析 `python3` → `/usr/bin/python3` → macOS 系统 3.9。分布式模板必须以最低可行版本为目标，或使用 `from __future__ import annotations` 处理 PEP 604 语法。参见 `cli/backend/script-conventions.md` → **CRITICAL: PEP 604 Annotations Require `from __future__ import annotations`** 了解硬性规则和审计检查。

**规则 6**：从 Python 调用 Python 时，使用 `sys.executable`：

```python
import sys
import subprocess

# 不好 - 硬编码命令
subprocess.run(["python3", "other_script.py"])

# 好 - 使用当前解释器
subprocess.run([sys.executable, "other_script.py"])
```

### 2. 路径处理

| 假设 | macOS/Linux | Windows |
|------------|-------------|---------|
| `/` 分隔符 | ✅ 可用 | ⚠️ 有时可用 |
| `\` 分隔符 | ❌ 转义字符 | ✅ 原生支持 |
| `pathlib.Path` | ✅ 可用 | ✅ 可用 |

**规则（Python）**：对所有路径操作使用 `pathlib.Path`。

```python
# 不好 - 字符串连接
path = base + "/" + filename

# 好 - pathlib
from pathlib import Path
path = Path(base) / filename
```

#### 逻辑键 vs 文件系统路径（TypeScript）

路径字符串有两个不同的角色。**要区分对待它们**。

| 角色 | OS 原生（`\` on Windows） | 始终 POSIX（`/`） |
|------|---------------------------|--------------------|
| `fs.readFileSync(p)` / `path.join(cwd, x)` 用于 fs 调用 | ✅ 必需 | ❌ Windows 上可能失败 |
| `Map<relPath, content>` 键、JSON 字段、哈希字典键、任何跨 OS 持久化的东西 | ❌ 跨 OS 不匹配 | ✅ 必需 |

**规则**：任何路径字符串跨 OS 或持久化（被其他 OS 消费的 Map 键、JSON 字段、哈希字典键）的地方，规范化为 POSIX。直接进入 `fs.*` 的地方，保留 OS 原生。

**单一真相来源**：`packages/cli/src/utils/posix.ts` 导出 `toPosix(p)`。不要在每个 `path.join` 站点散布 `replaceAll('\\', '/')` — 在边界应用一次 `toPosix`：收集器退出（Map 键进入哈希字典）或写入时间（`saveHashes` 在 `JSON.stringify` 之前）。

```typescript
// 不好 - 逻辑键携带 OS 原生分隔符
function collectTemplates(): Map<string, string> {
  const files = new Map<string, string>();
  for (const entry of walk(dir)) {
    files.set(path.join(".opencode", entry), readFile(entry));  // \ on Windows
  }
  return files;
}

// 好 - 在边界规范化
import { toPosix } from "../utils/posix.js";

function collectTemplates(): Map<string, string> {
  const files = new Map<string, string>();
  for (const entry of walk(dir)) {
    files.set(toPosix(path.join(".opencode", entry)), readFile(entry));
  }
  return files;
}

// 也可接受 - 写入侧防御（用于 saveHashes 等存储 helper）
function saveHashes(cwd: string, hashes: Record<string, string>): void {
  const normalized = Object.fromEntries(
    Object.entries(hashes).map(([k, v]) => [toPosix(k), v])
  );
  fs.writeFileSync(getHashesPath(cwd), JSON.stringify(normalized, null, 2));
}
```

**常见违规者**：`path.relative(cwd, fullPath)` 在 Windows 上产生 `\`。如果你然后使用该字符串作为哈希字典查找键（`hashes[relPath]`），先 `toPosix`，否则 Windows 上查找会失败。

### 3. 行尾

| 格式 | macOS/Linux | Windows | Git |
|--------|-------------|---------|-----|
| `\n`（LF） | ✅ 原生 | ⚠️ 某些工具 | ✅ 规范化 |
| `\r\n`（CRLF） | ⚠️ 额外字符 | ✅ 原生 | 转换 |

**规则 1**：使用 `.gitattributes` 强制一致的行尾。

```gitattributes
* text=auto eol=lf
*.sh text eol=lf
*.py text eol=lf
```

**规则 2**：在跨平台**内容**哈希或比较之前，规范行尾。`.gitattributes` 只管理 git checkout — 用户、脚本或 `core.autocrlf=true` 编写的文件可能仍以 CRLF 到达，对于相同内容 `sha256(LF)` ≠ `sha256(CRLF)`。

```typescript
// 不好 - 启用 autocrlf=true 的 Windows 用户得到不同的哈希
export function computeHash(content: string): string {
  return createHash("sha256").update(content, "utf-8").digest("hex");
}

// 好 - 在哈希之前规范化，使逻辑内容哈希相同
export function computeHash(content: string): string {
  const normalized = content.replace(/\r\n/g, "\n");
  return createHash("sha256").update(normalized, "utf-8").digest("hex");
}
```

在哈希跨 OS 边界的任何地方应用此规则（模板哈希字典、存储在 JSON 中的内容指纹、与远程注册表的完整性检查）。

### 4. 环境变量

| 变量 | macOS/Linux | Windows |
|----------|-------------|---------|
| `HOME` | ✅ 已设置 | ❌ 使用 `USERPROFILE` |
| `PATH` 分隔符 | `:` | `;` |
| 大小写敏感性 | ✅ 区分大小写 | ❌ 不区分大小写 |

**规则 1**：使用 `pathlib.Path.home()` 而不是环境变量。

```python
# 不好
home = os.environ.get("HOME")

# 好
home = Path.home()
```

**规则 2**：将环境变量注入 shell 命令时，为将实际解析命令的 shell 生成前缀。不要仅从 OS 选择语法。Windows 上的 AI 工具 "Bash" 可能通过 PowerShell、Git Bash、MSYS2 或其他 POSIX 类 shell 执行。

```javascript
// 不好 - 当主机 shell 是 PowerShell 时会失败
command = `export CONSTELLA_CONTEXT_ID=${shellQuote(contextKey)}; ${command}`;

// 好 - shell-dialect-aware 命令前缀
const prefix = process.platform === "win32" && !isWindowsPosixShell(process.env)
  ? `$env:CONSTELLA_CONTEXT_ID = ${powershellQuote(contextKey)}; `
  : `export CONSTELLA_CONTEXT_ID=${shellQuote(contextKey)}; `;
command = `${prefix}${command}`;
```

在 Windows 上，将 `MSYSTEM`、`MINGW_PREFIX`、`OSTYPE=msys|mingw|cygwin`、`SHELL=...bash` 或特定平台 Git Bash 设置视为 POSIX shell 信号。没有 POSIX shell 信号时，将 PowerShell 作为 Windows 默认值。

还要使重复注入检测感知 shell。只匹配 `export VAR=` 的守卫会错过 PowerShell 的 `$env:VAR = ...` 形式，可能第二次包装已经正确的命令。

### 5. 命令可用性

| 命令 | macOS/Linux | Windows |
|---------|-------------|---------|
| `grep` | ✅ 内置 | ❌ 不可用 |
| `find` | ✅ 内置 | ⚠️ 语法不同 |
| `cat` | ✅ 内置 | ❌ 使用 `type` |
| `tail -f` | ✅ 内置 | ❌ 不可用 |

**规则**：尽可能使用 Python 标准库而不是 shell 命令。

```python
# 不好 - tail -f 在 Windows 上不可用
subprocess.run(["tail", "-f", log_file])

# 好 - 跨平台实现
def tail_follow(file_path: Path) -> None:
    """Follow a file like 'tail -f', cross-platform compatible."""
    with open(file_path, "r", encoding="utf-8", errors="replace") as f:
        f.seek(0, 2)  # Go to end
        while True:
            line = f.readline()
            if line:
                print(line, end="", flush=True)
            else:
                time.sleep(0.1)
```

### 可选咨询检查（在 Agent 沙箱中）

AI CLI 子进程在用户正常终端有网络访问时可能网络出站被禁用。在本地 CLI 已经暴露所需信息时，优先使用本地 CLI 探测而不是可选网络探测。

**规则 1**：不要让失败的可选咨询检查消耗每个会话一次的标记。仅在脚本解析可用值并能做预期决策后才写入标记。否则瞬态沙箱/网络失败会在会话剩余时间内隐藏提示。

**规则 2**：如果本地命令可以提供所需值，用短超时和捕获的输出尝试它。例如，`constella --version` 已经运行 CLI 的版本比较逻辑，可以支持可操作的更新提示而不重复 npm 注册表解析。

**规则 3**：保持咨询检查在失败时静默。用户上下文输出不得因咨询检查无法完成而失败或变得嘈杂。

### 6. 文件编码

| 默认编码 | macOS/Linux | Windows |
|------------------|-------------|---------|
| 终端 | UTF-8 | 通常是 CP1252 或 GBK |
| 文件 I/O | UTF-8 | 系统区域设置 |
| Git 输出 | UTF-8 | 可能不同 |

**规则**：始终明确指定 `encoding="utf-8"` 并使用 `errors="replace"`。

> **检查清单**：当编写打印非 ASCII 的脚本时，你配置了 stdout 编码了吗？
> 参见 `backend/script-conventions.md` 了解特定模式。

```python
# 不好 - 依赖系统默认
with open(file, "r") as f:
    content = f.read()

result = subprocess.run(cmd, capture_output=True, text=True)

# 好 - 显式编码与错误处理
with open(file, "r", encoding="utf-8", errors="replace") as f:
    content = f.read()

result = subprocess.run(
    cmd,
    capture_output=True,
    text=True,
    encoding="utf-8",
    errors="replace"
)
```

**Git 命令**：强制 UTF-8 输出编码：

```python
# 强制 git 输出 UTF-8
git_args = ["git", "-c", "i18n.logOutputEncoding=UTF-8"] + args
result = subprocess.run(
    git_args,
    capture_output=True,
    text=True,
    encoding="utf-8",
    errors="replace"
)
```

---

## 变更传播检查清单

进行平台相关变更时，检查**所有这些位置**：

### 命令 / Skills 同步
- [ ] 新命令/skill 已添加到所有平台（claude, cursor, codex, opencode 等）
- [ ] 每个平台的测试文件使用新条目更新了 `EXPECTED_COMMAND_NAMES` / `EXPECTED_SKILL_NAMES`
- [ ] 如果添加新必需命令，已更新平台集成规范的必需命令表
- [ ] 命令格式符合平台约定（参见 `platform-integration.md` → 按平台的命令格式）

### 文档和帮助文本
- [ ] Python 文件顶部的文档字符串
- [ ] `--help` 输出 / argparse 描述
- [ ] README 中的使用示例
- [ ] 建议命令的错误消息
- [ ] Markdown 文档（`.md` 文件）

### 代码位置
- [ ] `src/templates/` - 新项目的模板文件
- [ ] `.constella/scripts/` - 项目自己的脚本（如果是自托管）
- [ ] `dist/` - 构建输出（变更后重建）

### 搜索模式
```bash
# 查找可能需要更新的所有位置
grep -r "python [a-z]" --include="*.py" --include="*.md"
grep -r "{{PYTHON_CMD}}\\|python3\\|python " --include="*.py" --include="*.md"
```

---

## 提交前检查清单

在提交跨平台代码之前：

- [ ] 面向用户的 Python 调用是平台感知的（Windows 上用 `python`，其他平台用 `python3`）或使用 `{{PYTHON_CMD}}`
- [ ] Python 到 Python 的子进程使用 `sys.executable`
- [ ] 所有路径使用 `pathlib.Path`
- [ ] 没有硬编码路径分隔符（`/` 或 `\`）
- [ ] 用作逻辑/持久化键（Map 键、JSON 字段、哈希字典键）的路径字符串通过 `toPosix()` 规范化；`fs.*` 调用保留 OS 原生路径
- [ ] 跨 OS 的内容哈希在哈希之前规范化行尾（`\r\n` → `\n`）
- [ ] 可能有遗留污染的跨 OS JSON 带有 `__version` 哨兵，加载器丢弃未知/遗留版本
- [ ] 没有平台特定命令没有回退（例如 `tail -f`）
- [ ] 可选咨询检查在失败时不在每个会话标记上烧录
- [ ] 所有文件 I/O 指定 `encoding="utf-8"` 和 `errors="replace"`
- [ ] 所有子进程调用指定 `encoding="utf-8"` 和 `errors="replace"`
- [ ] Git 命令使用 `-c i18n.logOutputEncoding=UTF-8`
- [ ] 外部工具 API 格式从文档验证
- [ ] 文档与代码行为匹配
- [ ] 运行搜索查找所有受影响位置

### 7. 外部工具 API 契约

与外部工具（Claude Code、Cursor 等）集成时，它们的 API 契约是**隐式假设**。

**规则**：从官方文档验证 API 格式，不要猜测。

```python
# 不好 - 猜测的格式
output = {"continue": True, "message": "..."}

# 好 - 从文档验证的格式
output = {
    "hookSpecificOutput": {
        "hookEventName": "SessionStart",
        "additionalContext": "..."
    }
}
```

> **警告**：不同 hook 类型可能有不同的输出格式。
> 始终检查每个 hook 事件的特定文档。

---

## 跨平台持久化 JSON：模式迁移哨兵

当 JSON 文件可能跨 OS 读写（提交到 git、通过云同步、在机器之间复制）**且旧格式可能已在用户磁盘上存在跨平台污染**（Windows 样式键、CRLF 派生的哈希、locale 编码字符串）时，添加 `__version` 哨兵，让加载器丢弃旧格式，这样写入器会重新生成干净数据。

**为什么不原地迁移？** 路径键迁移（`\` → `/`）加上哈希输入迁移（CRLF → LF re-hash）加上编码修复是相关的 — 尝试转换旧有效载荷可能产生错误的值。丢弃并重新生成是**安全的**：数据可从磁盘重新计算，`loadX` 返回 `{}` 触发现有 init/update 路径重建规范条目。

```typescript
const SCHEMA_VERSION = 2;
type StoredV2 = { __version: number; hashes: Record<string, string> };

export function loadHashes(cwd: string): Record<string, string> {
  const file = getHashesPath(cwd);
  if (!fs.existsSync(file)) return {};

  try {
    const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as unknown;

    // 拒绝遗留平面格式（无 __version）和未知版本。
    // 下一次 saveHashes / initializeHashes 将写入新的 v2 文件。
    if (
      !parsed ||
      typeof parsed !== "object" ||
      (parsed as StoredV2).__version !== SCHEMA_VERSION ||
      typeof (parsed as StoredV2).hashes !== "object"
    ) {
      return {};
    }
    return (parsed as StoredV2).hashes;
  } catch {
    return {};
  }
}

export function saveHashes(cwd: string, hashes: Record<string, string>): void {
  const payload: StoredV2 = { __version: SCHEMA_VERSION, hashes };
  fs.writeFileSync(getHashesPath(cwd), JSON.stringify(payload, null, 2));
}
```

**何时应用**：
- 哈希字典 / 内容指纹（例如 `.template-hashes.json`）
- 可以从权威源重新计算的缓存文件
- 任何格式变更与跨平台修复相关的跨 OS 持久化文件

**何时不应用** — 如果丢失数据伤害用户（任务状态、草稿、用户输入的设置）。在那里使用真正的迁移。仅当数据可重新计算时，哨兵 + 丢弃是安全的。

**参考**：`packages/cli/src/utils/template-hash.ts` v2 信封。

---

## JSON/外部数据防御检查

解析 JSON 或外部数据时，TypeScript 类型仅在**编译时**有效。运行时数据可能不匹配。

**规则**：在使用必填字段之前始终添加防御检查。

```typescript
// 不好 - 信任 TypeScript 类型定义
interface MigrationItem {
  from: string;  // TypeScript 说是必需的
  to?: string;
}

function process(item: MigrationItem) {
  const path = item.from;  // 运行时：可能是 undefined!
}

// 好 - 使用前防御检查
function process(item: MigrationItem) {
  if (!item.from) return;  // 跳过无效数据
  const path = item.from;  // 现在有保证了
}
```

**何时应用**：
- 解析 JSON 文件（清单、配置）
- API 响应
- 用户输入
- 任何来自外部源的数据

**模式**：检查存在 → 然后使用

```typescript
// 过滤模式 - 跳过无效项
const validItems = items.filter(item => item.from && item.to);

// 早期返回模式 - 无效时退出
if (!data.requiredField) {
  console.warn("Missing required field");
  return defaultValue;
}
```

---

## 常见错误

### 1. "它在我的 Mac 上可以工作"

```python
# 开发者的 Mac
subprocess.run(["./script.py"])  # 可以工作！

# 用户的 Windows
subprocess.run(["./script.py"])  # FileNotFoundError
```

### 2. "shebang 应该处理它"

```python
#!/usr/bin/env python3
# 这一行在 Windows 上被忽略
```

### 3. "我更新了模板"

```
src/templates/script.py  ← 已更新
.constella/scripts/script.py  ← 忘了同步！
```

### 4. "Python 3 始终是 python3"

```bash
# 开发者的 Mac/Linux
python3 script.py  # 可以工作！

# 用户的 Windows（来自 python.org 的 Python）
python3 script.py  # 'python3' 无法识别
python script.py   # 可以工作！

# Constella 文档/配置应该说规则，而不是到处猜测一个别名
{{PYTHON_CMD}} script.py
```

### 5. "UTF-8 在各地是默认值"

```python
# 开发者的 Mac（UTF-8 默认）
subprocess.run(cmd, capture_output=True, text=True)  # 可以工作！

# 用户的 Windows（GBK/CP1252 默认）
subprocess.run(cmd, capture_output=True, text=True)  # 乱码中文/Unicode
```

> **注意**：stdout 编码也受影响。参见 `backend/script-conventions.md` 了解修复方法。

---

## 恢复：当你发现平台 Bug 时

1. **修复直接问题**
2. **搜索类似模式**（grep 代码库）
3. **用新模式更新本指南**
4. **如果反复出现，添加到提交前检查清单**

---

**核心原则**：如果不明确，就是假设。而假设会破坏。

---

## 发布检查清单：版本化文件

发布新版本时，确保**所有版本化文件**已创建/更新：

- [ ] `src/migrations/manifests/{version}.json` - 迁移清单存在
- [ ] 清单有正确的版本、描述、变更日志
- [ ] `pnpm build` 复制清单到 `dist/`
- [ ] 测试从旧版本升级（不仅仅是相邻版本）

**为什么这很重要**：缺少清单会导致用户从旧版本升级时出现"path undefined"错误。

```bash
# 验证所有预期的清单存在
ls src/migrations/manifests/

# 测试升级路径
node -e "
const { getMigrationsForVersion } = require('./dist/migrations/index.js');
console.log('From 0.2.12:', getMigrationsForVersion('0.2.12', 'CURRENT').length);
"
```
