# 扩展接入规范：一个目录，Skill + CLI

适用范围：`@forgeax/game` 的统一目录扩展契约。本文是扩展作者和集成同学的交付规范；具体发布版本以包元数据为准，旧游戏升级见 [Engine 0.3.3 → 0.4.3 迁移指南](engine-0.3.3-to-0.4.3-migration.md)。

扩展作者交付自包含目录，放入 `extensions/`。不修改主 CLI 分发或安装器，不注册 MCP、全局命令或脱离 CLI 生命周期的后台服务。扩展是随包审核发布的可信代码，不是沙箱代码。

> [!NOTE]
> 本文对应本次通用宿主 API 1 改动，不代表已发布的 0.3.10 已具备这些新增能力。使用前核对候选/发布版本；Engine 的兼容要求与宿主 API 分开检查。

## 谁负责什么

| 角色 | 交付与职责 | 不需要做 |
|:--|:--|:--|
| 扩展作者 | 一个目录：清单、Skill、CLI 业务模块，以及业务测试/配置说明 | 修改主 CLI、操作 Agent 配置、实现 enable/disable 安装逻辑 |
| 主插件维护者 | 审核目录与依赖，构建打包，执行接入验收，随主包发布 | 为每个扩展添加命令分支 |
| 最终用户 | 安装主插件、初始化项目、显式启用需要的扩展 | 全局安装每个扩展的 CLI、配置资产 MCP |

当前只支持随主包交付的内置扩展。把目录放进游戏项目不会自动加载；也没有 `install <第三方目录或URL>` 命令。作者把目录交给主插件维护者或提交到主仓的 `extensions/`。

## 目录契约

```text
extensions/example/
  extension.json
  cli.mjs
  skills/example-use/SKILL.md
  skills/example-use/references/guide.md  # 可选，随 Skill 安装
  resources/                            # 可选，随包分发，不写入 Agent 目录
```

```json
{"schemaVersion":1,"id":"example","version":"1.0.0","skills":["skills/example-use"],"cli":"cli.mjs","requiresHostApi":1,"resources":["resources"]}
```

CLI 为 Node ESM 模块。仓内可用 `cli.ts` 编写，打包器自动编译成 `cli.mjs` 并收拢静态依赖；交付目录可直接提供 `cli.mjs`。清单路径不得越界或通过软链接逃逸。列出的 Skill 目录整体复制和挂载，包括 references；Markdown 中的 `{{CLI}}` 会展开，二进制文件保持原字节。

| 字段 | 当前约束 |
|:--|:--|
| `schemaVersion` | 固定数字 `1` |
| `id` | 与目录名一致；小写字母开头，后接小写字母、数字或连字符，总长不超过 64 |
| `version` | `数字.数字.数字`，如 `1.0.0`；当前不接受预发布后缀 |
| `skills` | 至少一个 `skills/<名称>`；名称使用小写字母开头及小写字母、数字、连字符 |
| `cli` | 构建契约固定为 `cli.mjs`；源文件二选一 `cli.mjs` 或 `cli.ts`，不要同时提交过期 JS 和新 TS |
| `requiresHostApi` | 可选，默认 `1`；最低宿主接口版本，正整数。高于宿主能力时，构建/enable/run 在加载业务代码前明确拒绝 |
| `resources` | 可选，默认 `[]`；显式列出的目录或文件，路径相对扩展根。完整复制到包内同位置；不允许覆盖 cli/清单/skills |
| `executionMode` | 可选，默认 `whole-call`；长时间 UI/确认交互选 `segmented`，实际业务步骤进入 `context.withOperation` |

ID 不得占用主命令：`install/init/uninstall/update/use/doctor/preview/devkit/agents/help/version`。
Skill 名应带能力前缀并保持唯一，避免不同扩展占用同一个 Agent 目录。
可以有 `src/` 辅助模块，通过静态 import 收入 CLI 构建产物。运行时读取的 WGSL、JSON、图片、浏览器 UI 等必须纳入 `resources`；通过 `new URL('./resources/file', import.meta.url)` 读取。浏览器/运行时构建由作者在交付前完成，不在主包添加 `if (id === '某插件')` 构建分支。动态依赖和仓库外路径不会自动变成闭包。

未声明的资源不复制；软链接、隐藏文件、node_modules、credentials/secrets 目录、私钥和 sourcemap 拒绝进入资源/Skill 分发树。此规则不能替代内容敏感信息扫描，维护者仍须检查候选 tarball。不要把凭证放进资源目录。
新增第三方依赖须由维护者加入主仓依赖并审核许可；不要求用户在游戏里为扩展运行 npm install。

## CLI 契约

```js
export async function check(context, args) {
  // 校验启用参数、服务和认证。失败抛错，不写 Agent 配置。
  return { endpoint: "https://example.com" }; // 仅非秘密配置
}

export async function run(context, args) {
  // 实现领域操作；返回可 JSON 序列化的结果，失败抛 Error。
  return { items: [] };
}
```

读取 `stateDir/config.json` 获取 check 返回的配置；后续操作必须实际采用这些配置，不能重新使用默认地址/模型。临时业务状态只能放 `stateDir/data/`。用户明确要求导入的成果写入项目资产目录，不属于卸载垃圾。

| 字段 | 含义 |
|:--|:--|
| `projectRoot` | 当前游戏的根目录 |
| `stateDir` | 当前扩展的受管目录：`.forgeax/extensions/<id>` |
| `packageVersion` | 正在运行的主插件版本，不是扩展清单版本 |
| `hostApiVersion` | 当前公开宿主接口版本 `1`；不要用 Game 补丁版本硬门槛替代接口兼容检查 |
| `extensionId` / `extensionVersion` | 当前扩展清单身份，不由业务模块猜测 |
| `instanceId` | 每次 check/run 的唯一身份 |
| `cancellation` | AbortSignal：Ctrl-C、SIGTERM 或协作停用请求。扩展把它传给可取消的请求/任务并停止后续阶段 |
| `withOperation(name, fn)` | 有界业务操作的项目互斥；segmented 模式用于写状态、调用 Provider、修改 Engine 项目等步骤 |
| `onCleanup(fn)` | 注册资源关闭回调；正常返回、抛错及取消收敛后，宿主逆序等待全部回调 |

这里的 CLI 是由 `forgeax-game <id> ...` 加载的业务模块，不是要求用户直接执行 `node cli.mjs`。模块顶层不得启动业务、读写项目或等待输入；行为放在导出的函数内。当前在主进程内加载，没有隔离沙箱。

- `check(context, args)`：接收移除 `--ide` 和 `--local` 后的 enable 参数；`--json` 仍会传入。返回非秘密 JSON 配置，不自行创建 `install.json` 或挂载 Skill。不能要求已有 stateDir，因为发布在检查成功后发生。
- `run(context, args)`：接收业务操作名及剩余参数，如 `["query", "--json"]`。所有领域参数由扩展自己校验。`doctor` 不是自动生成的，确有需要时由扩展实现。
- 返回值必须能 JSON 序列化；不要返回 `undefined`、BigInt、循环对象或完整秘密请求。失败抛出 `Error("稳定错误码: 安全说明")`，不要把含 Key 的原始错误直接上抛。
- 需要结构化诊断时，可在稳定错误码后编码 JSON（`reason`、`stage`、`verification`、`diagnostics`）。宿主保留有界的失败原因、源码位置和修复建议，放在 `error.details`；不转发任意响应、遥测、请求头或凭证。简洁/`--pretty` 输出的字段与失败退出码一致。作者仍须在抛错前剔除秘密。
- 宿主也拒绝顶层 `ok:false`，避免内部失败被包装成成功；合法空列表仍是成功。推荐扩展直接抛稳定错误，不使用双层成功/失败 envelope。
- 交互认证提示只用于交互终端；`--json` 缺凭证时立即失败，不无限等待。当前没有公共凭证存储 API，扩展自行使用受控私有文件/环境变量；不要声称安装器会自动管理任意凭证。

主入口统一处理 `enable/disable`、选定 Agent、版本固定、Skill 发布、失败回滚、文件所有权和项目登记。`run` 只收到业务操作；未启用时主入口拒绝调用。业务实现不能输出额外 stdout，诊断用 stderr；主入口输出 JSON envelope 和退出码。

```json
{"schemaVersion":"1.0.0","command":"example.query","ok":true,"value":{"items":[]}}
```

```json
{"schemaVersion":"1.0.0","command":"example.query","ok":false,"error":{"code":"example_access_denied","message":"example_access_denied: access rejected"}}
```

正常完成退出 0，失败非 0。顶层 `failed` 是批处理失败计数字段；当前主入口看到 `failed > 0` 会报告失败，普通业务不要用这个字段表达其他含义。合法空列表与服务失败要分开。

### 长时间 UI 和生命周期

短命令不增加任何配置，沿用 `whole-call`：一次 run 全程持有项目锁。需要保持 UI 打开、等待确认并接受其他业务命令时，清单设置 `"executionMode":"segmented"`。宿主登记这次调用，UI 空闲不占业务锁；每个有副作用的步骤用 `withOperation`。互斥冲突明确报 `extension_busy`，不无限等待。

```js
export async function run(context, args) {
  const config = JSON.parse(await readFile(context.stateDir + "/config.json", "utf8"));
  if (args[0] === "query") {
    return context.withOperation("query", async () => {
      context.cancellation.throwIfAborted();
      return query(config, { signal: context.cancellation });
    });
  }
  const ui = await openOwnedUi({ signal: context.cancellation });
  context.onCleanup(() => ui.close());
  await ui.closed; // 正常关闭或 cancellation 收敛；run 不得提前返回
  return { closed: true };
}
```

上例 `query/openOwnedUi` 是作者业务函数，不是额外宿主 API。`check` 也支持 cleanup，但只用于检查期间的资源，不能启动一个留下来的服务。子进程必须随 run 存活、可停止且在 cleanup 中等到退出；禁止 detached/unref 的孤儿进程。

| 场景 | 宿主行为 | 作者责任 |
|:--|:--|:--|
| UI 保持打开 | 登记活动，不持有整个等待期的业务锁 | 真正写入/调用 Provider 时使用 withOperation |
| 正常返回或失败 | 等待 cleanup，释放自有活动和锁 | 关闭自有监听器、连接和子进程 |
| Ctrl-C / SIGTERM | 触发 cancellation，等待 run 与 cleanup 收敛 | 响应信号，停止新阶段，保存应保留的成果 |
| disable / uninstall | 向 segmented 活动发送私有取消标记，等待清理，成功后移除注册 | 不依赖用户凭证或远端服务来关闭本地资源 |
| 超时、崩溃、记录身份不明 | 保留注册和状态并失败，不盲删、不杀任意 PID | 先检查恢复；不要宣称卸载已完成 |

停用等待上限为 5 秒；活动中的旧式 whole-call 命令直接返回 busy，先结束该命令再停用。`uninstall --all-projects` 在移除任何注册前预检所有项目，不能部分失败后继续移除全局主注册。对已独立安装的外部服务，Game 插件只连接它，不拥有也不停止它。

| 操作 | 约定 |
|:--|:--|
| `forgeax-game example enable --ide codex` | check 成功后发布配置、Skills 和安装记录 |
| `forgeax-game example query --json` | 通用路由调用 run，业务名由扩展决定 |
| `forgeax-game example disable` | 不加载业务代码/不访问远端，先完成自有活动的协作关闭，再撤销 Skills、记录、配置和临时数据；重复调用安全 |
| `forgeax-game uninstall` | 当前项目级联停用所有扩展，再清理主插件注册 |
| `forgeax-game uninstall --all-projects` | 清理已登记项目；不可访问项目报错，不能声称完全卸载 |

`npm uninstall` 只删除 npm 安装，不替代产品的卸载命令。项目登记位于用户 `.forgeax/extension-projects.json`；不扫描磁盘。可用 `FORGEAX_USER_STATE_DIR` 隔离管理状态。

**作用域注意**：扩展 Skill/启用记录属于项目；主 `forgeax` MCP 的作用域由 Agent 决定，Codex 是用户级。主插件 `uninstall` 不只是当前目录文件清理，也会移除所选 Agent 的主注册。只想停用当前项目的某项能力，使用 `<id> disable`。

`enable --ide` 是增量挂载：再次选择其他 Agent 会保留并刷新此前登记的挂载，移除不再交付的旧文件；用户编辑过的文件拒绝覆盖。`disable` 一次回收该扩展的所有已登记挂载，不支持按 Agent 单独停用。主插件或扩展版本改变后要重新 enable；`update` 当前不会自动重新检查并启用各扩展。

受管目录只记录由新安装器登记的项目。`--all-projects` 不是旧版本目录或用户手工配置的全盘清理工具。npm 包内的扩展代码是共享分发内容，disable 撤销的是运行资格与项目注册，不删除共享包文件。

## Skill 契约

````markdown
---
name: example-use
description: Query the example service when the user requests its data.
---

从项目目录执行 `{{CLI}} query --json`，根据返回结果继续操作。
失败时说明阻塞，不伪造结果。
````

安装器把 `{{CLI}}` 替换为固定版本 npx 命令或本地候选的绝对入口。无需全局 CLI，不添加每个 Skill 的转发脚本。Skill 不拼 curl，不读取密钥，不规定固定候选或游戏风格。

## 作者与维护者的接入步骤

1. 作者交付上述目录，并说明外部服务、网络前提、凭证环境变量、业务命令、返回结构和写入位置。没有外部服务的扩展不增加认证步骤。
2. 维护者把目录放入主仓 `extensions/<id>`，审核所有权、依赖和安全边界；不要在主 `src/cli/dispatch.ts` 添加领域分支。
3. 在主仓执行以下检查并打候选包。源码单测不能替代已安装包验证。

```bash
bun run typecheck
bun test test/*.test.ts
npm pack --pack-destination /absolute/path/to/artifacts
bun run release:check -- /absolute/path/to/artifacts/forgeax-game-<版本>.tgz
```

4. 安装候选到独立 consumer，创建空项目，在项目目录使用该候选的绝对 CLI。下列路径为示例，须替换；`install --ide codex --local` 会更新本机 Codex 注册，测试前应明确这是预期操作。

```bash
npm install --prefix /absolute/path/to/consumer /absolute/path/to/artifacts/forgeax-game-<版本>.tgz
mkdir /absolute/path/to/new-game
cd /absolute/path/to/new-game
node /absolute/path/to/consumer/node_modules/@forgeax/game/dist/main.js install --ide codex --local
node /absolute/path/to/consumer/node_modules/@forgeax/game/dist/main.js init
node /absolute/path/to/consumer/node_modules/@forgeax/game/dist/main.js example enable --ide codex --local --json
node /absolute/path/to/consumer/node_modules/@forgeax/game/dist/main.js example query --json
node /absolute/path/to/consumer/node_modules/@forgeax/game/dist/main.js example disable --json
```

`query` 是业务示例，作者须提供对应实现。发布到 npm 前不要让使用者执行指向未发布版本的 npx 命令。Skill 会写入当前候选的绝对 CLI；不要移动或删除该 consumer。

5. 验证停用后的调用失败、重新启用及主插件卸载。资源要在已安装目录可读取；Skill 引用文档能打开；交互扩展要实测 UI 空闲时业务操作、取消、disable/uninstall 后监听端口和子进程消失；失败/忙碌时不得删除注册。涉及远端服务必须补真实业务证据；维护者 review 后才发布主包。

## 现有扩展迁移到本契约

| 现状 | 需要调整 |
|:--|:--|
| 只有 SKILL.md + check/run 的短命令 | 不必改调用模式；仍用 whole-call，补必要的资源声明/业务测试 |
| Skill 链接 references | 直接交付 Skill 子目录，不再为主包单独内联文档 |
| UI/WGSL/JSON 在仓库外层 | 归入扩展交付目录，预构建并声明 resources，修正相对读取路径 |
| 主宿主已有某扩展 id 的特例 | 改用通用清单/上下文，不能将整个旧宿主分支一起合入 |
| 精确限定 Game 0.3.x | 检查 requiresHostApi；Engine 的真实编译/导入兼容继续单独验证 |
| detached 服务 / 返回后仍有 Preview 进程 | 使用明确外部服务，或改为 segmented 前台会话 + cancellation + onCleanup |
| 配置写入后不读、失败仍返回成功 | run 读取 config；失败抛安全稳定错误，并通过实际宿主 envelope 验收 |

本接口不会修复扩展内部的服务地址、资产格式、Engine 编译适配或生成质量；这些仍需作者按真实业务链路验收。

## 发布后的用户操作

### 内置目录与前置条件

内置不等于自动启用。主插件只分发下列目录，用户用统一的
`<extension> enable` 注册 Skill，用 `<extension> disable` 回收；业务产物保留。

| 扩展 | 命令 ID | 运行前提 |
|:--|:--|:--|
| 资产库 | `asset3d` | 对应资产服务与 Sandbox Key；EA 需显式 base URL |
| 叙事工坊 | `narrative` | 可由扩展启动的独立服务；配置 Key 或已安装的宿主模型 |
| 场景生成器 | `scene-generator` | 已运行的 Scene Generator API，enable 时传 base URL |
| 角色 | `gen3d` | 本地导入/预览可独立运行；在线生成需要对应供应商凭证和确认 |
| 声音工坊 | `audio` | 专用音频服务凭证，不借用资产库 Key |
| VFX | `forgeax-vfx-generation` | 当前项目的 Engine 0.4.3；原生源导入当前 assets，使用 Engine Preview |

维护者接收作者预构建的完整目录，只声明实际 CLI 所需的资源；演示工程、历史构建和
验收快照不进入内置交付。主插件保持通用安装/打包逻辑，不添加业务 ID 分支。

下列 `<已发布版本>` 必须替换为实际已发布版本，不能直接照抄占位符。用户不必全局安装 `forgeax-game`。

```bash
npx -y @forgeax/game@<已发布版本> install --ide codex
# 进入一个空白项目目录
npx -y @forgeax/game@<已发布版本> init
npx -y @forgeax/game@<已发布版本> example enable --ide codex
# 新开 Agent 会话，让模型按项目 Skill 使用能力
```

停用某项能力用 `... example disable`；卸载主插件用 `... uninstall`，需要清理登记项目时加 `--all-projects`。部分 Agent 需要新会话或重启才能更新 Skill/MCP 列表。Asset3D 的服务参数和认证见 [资产库使用说明](asset3d.md)。

## 清理和安全边界

- 安装器维护 `.forgeax/extensions/<id>/install.json` 中的路径和摘要；扩展不自行挂载或删除 Skill。
- 停用时，修改过的受管 Skill 移入 `.forgeax/extension-backups/`，退出 Agent 发现目录。其他用户文件保留。
- 已导入资产和游戏成果保留。项目外用户提供的共享凭证保留，卸载不销毁其他项目仍在使用的 Key。
- Engine SDK 随游戏创建的 `skills/forgeax-engine-*` 与发现挂载属于 SDK；卸载 Game 插件保留它们，不以名称前缀推断删除所有权。扩展自己的受管 Skill 仍由安装记录完整回收。
- 凭证仅从专属环境变量或私有文件读取，不写 Skill、命令参数、日志、config.json 或包产物。扩展有责任验证认证并在失败时终止。
- 网络操作限时限量；下载解压检查来源、路径和体积。Pack 源码交给 Engine 构建会执行代码，只接入受信服务。
- 只支持可信 Node CLI 目录；不引入市场、任意运行时、常驻服务或扩展依赖管理。
- 同一项目的扩展操作串行互斥；遇到 `extension_busy` 先确认是否仍有操作运行。进程异常终止可能留下锁，不把盲删锁写进 Skill 的自动恢复流程。

## 接入验收

- [ ] 新增目录，不修改主源码，打包后能启用和调用。
- [ ] check 失败不留下可发现 Skill 或启用记录。
- [ ] 重复 enable/disable 可执行；自定义文件不被覆盖或误删。
- [ ] disable 后 Skill 不被发现，业务操作报未启用。
- [ ] 主插件卸载级联回收，保留用户资产；不可访问项目明确报错。
- [ ] 真实业务路径通过；搜索、导入、可玩 Preview 分别记录，不互相替代。
- [ ] 发布包不含凭证、开发者路径、旧 Provider、维护脚本。

参考目录：[Asset3D](../extensions/asset3d/)；统一安装器：[manager.ts](../src/extensions/manager.ts)。

## 源码 review 索引

| 想看什么 | 文件 |
|:--|:--|
| 作者交付清单 | [extensions/asset3d/extension.json](../extensions/asset3d/extension.json) |
| 模型实际读取的指令 | [资产 Skill](../extensions/asset3d/skills/art-3d-asset-library/SKILL.md) |
| check/run 业务入口 | [extensions/asset3d/cli.ts](../extensions/asset3d/cli.ts) |
| HTTP 搜索、下载导入 | [aw-access.ts](../extensions/asset3d/src/aw-access.ts)、[library.ts](../extensions/asset3d/src/library.ts) |
| Engine 事务、Pack 身份读回 | [transaction.ts](../extensions/asset3d/src/transaction.ts)、[pack-readback.ts](../extensions/asset3d/src/pack-readback.ts) |
| 统一生命周期与接口 | [manager.ts](../src/extensions/manager.ts)、[contract.ts](../src/extensions/contract.ts) |
| 命令分发与主插件级联卸载 | [dispatch.ts](../src/cli/dispatch.ts) |
| 自动发现和打包扩展 | [build.mjs](../build.mjs) |
| 生命周期测试 | [extensions.test.ts](../test/extensions.test.ts)、[真实已安装包验收脚本](../scripts/accept-extension-lifecycle.mjs) |

Asset3D 是复杂业务示例，不要求其他作者照搬其事务、下载解压或文件数量。最小扩展只需清单、一个 Skill 和一个 check/run 模块。
