# 插件 SDK（starter）

## 脚手架：`pi-web-ui plugin create`

不用手拷文件，一条命令生成最小可跑骨架（`manifest.json` + `index.mjs` + `client/entry.mjs` + `README.md`，SDK 自动拷进 `sdk/`）：

```bash
pi-web-ui plugin create my-plugin --template minimal    # 零权限，可直接激活
pi-web-ui plugin create my-plugin --template ui-slot    # composer.actions 按钮 + onUiAction 示例
pi-web-ui plugin create my-plugin --template agent-tool # registerAgentTool 示例
pi-web-ui plugin create my-plugin --template renderer   # view:false + renderers 示例
```

常用选项：`--dir <插件父目录>`（默认 `<dataDir>/plugins`，也可用 `--data-dir` 指定数据目录）、`--force`（目标已存在时覆盖）。生成后会自动做一次 manifest 基础校验 + 生成文件的 `node --check`，有 warning 会直接打印；生效只需刷新浏览器（或发 `plugins_reload`），未运行则下次启动生效。

---

`@pi-web-ui/plugin-sdk` 是写插件的**起手包**：`index.mjs`（零依赖纯 ESM，可直接拷进插件目录）+ `index.d.ts`（宿主接口精简类型，编辑器补全用）。

> 和 `server/plugins.ts` 的全量 `PluginHost` 是**同语义的精简版**：运行时以宿主实际注入为准，宿主版本见 `PLUGIN_API_VERSION`。SDK 只求“写得顺”，不做运行时 polyfill。

## 怎么用

1. 把 `index.mjs`（和要补全就把 `index.d.ts`）拷进你的插件目录（如 `my-plugin/sdk/`）；
2. 服务端入口：

```js
import { definePlugin, selectOptions } from "./sdk/index.mjs";

export default definePlugin({
	async activate(host) {
		const tone = String(host.getSettings().tone ?? "short");
		const off = host.ui.register({
			slot: "composer.actions",
			id: "tone",
			label: "语气",
			kind: "select",
			action: "my-plugin:tone",
			value: tone,
			options: selectOptions([
				{ value: "short", label: "简短" },
				{ value: "full", label: "详细" },
			]),
		});
		// 反激活时注销（可选，宿主也会统一清理）
		return;
	},
});
```

3. 客户端视图（`client/entry.mjs`）：

```js
import { defineView, onUiAction } from "./sdk/index.mjs";

export default defineView({
	mount(el, ctx) {
		const status = document.createElement("div");
		status.textContent = "就绪";
		el.appendChild(status);
		// 动作回调走宿主桥（client bundle 与宿主同页，window 直达），
		// 要落盘/跨端再经 ctx.send 发给服务端入口。
		return onUiAction("my-plugin:tone", (itemId, value) => {
			status.textContent = `语气：${value ?? ""}`;
			ctx.send({ action: "my-plugin:tone", value });
		});
	},
});
```

注意 `select` 的切换值是第二个参数：`onUiAction("my-plugin:tone", (itemId, value) => …)`。

## 本地单测（createMockHost）

插件作者不用起服务就能测 `activate`：SDK 自带 `createMockHost(overrides?)`（零依赖纯 ESM，`node --test` / vitest 均可）：

```js
// my-plugin/index.test.mjs —— 跑法：node --test index.test.mjs
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import plugin from "./index.mjs";
import { createMockHost } from "./sdk/index.mjs";

describe("my-plugin", () => {
	it("activate 注册 UI + 读注入的设置", async () => {
		const host = createMockHost({ settings: { tone: "full" } });
		await plugin.activate(host);
		const reg = host.calls.find((c) => c.method === "ui.register");
		assert.ok(reg, "应该调过 host.ui.register");
		assert.equal(reg.args[0].value, "full"); // 读到的是注入的预设
	});

	it("reset() 清空记录；emit 模拟客户端消息", async () => {
		const host = createMockHost();
		await plugin.activate(host);
		host.reset();
		assert.equal(host.calls.length, 0);
		assert.equal(host.logs.length, 0);
		host.mock.emit("onMessage", { action: "my-plugin:tone" }, "client-1");
	});
});
```

要点：

- `host.calls`：全量调用记录 `{ method, args, seq }`（嵌套按点分路径，如 `"ui.register"`、`"fs.readText"`；`log`/`storage` 也记；`seq` 为本次窗口内序号）。`host.mock.calls(method?)` 按方法名过滤；`host.logs`：分级条目 `{ level, text }`；`host.reset()` 只清空两者（settings/handlers/注册表这些 fake 状态保留，要全新状态就重新 `createMockHost()`，零成本）。
- `overrides.settings` 是 `getSettings()` 返回的预设对象（同一引用）；`host.mock.setSettings(next)` 合并预设，`host.mock.emitSettings(next)` 合并 + 触发 `onSettingsChanged`（模拟用户在设置面板保存）。其余 `overrides` 键整体替换对应方法（`fs`/`ui`/`llm` 等命名空间传对象 = 按子键合并覆盖），被覆盖的方法仍进 `calls` 记录。
- 无注入回退（与宿主无 provider 时同语义）：`conversations.list/search` 回 `[]`、`get` 回 `null`；`prompt`/`steer`/`abortRun`/`chatWait`/`llm.complete`/`net.fetch`/`bash`/`scm` 回 `{ ok:false, … }`；`requestPermission` 回 `false`；`dialogs.select/input` 回 `{ ok:false }`、`confirm` 回 `false`；`notifyAction` resolve `null`；`schedule()` 只记调用不设真定时器（单测不泄漏句柄）。两处刻意差异：`chat()` 未注入时 resolve `{ ok:false }` 而不是 reject（真宿主 reject，由 `chatWait` 包成 `{ ok:false }`）；`fs.read/readPath` 给空 `Uint8Array`（浏览器侧 import 本文件也安全）。
- 注册/订阅类返回注销函数，传进来的 handler 存进 `host.mock.handlers[method]`，用 `host.mock.emit(method, ...args)` 同步触发（异步 handler 用 `await host.mock.emitAsync(...)` 按序 await，返回各返回值）。工具/命令/路由/定时任务的定义体另存进 `host.mock.agentTools/commands/routes/schedules`（活数组，注销即摘除；工具的 `execute` 可直接调）；`schedule()` 不设真定时器，用 `await host.mock.fireSchedules()` 手动触发。
- `ui` 是内存注册表（同 id 覆盖、单次上限 32 条）：`list()` 真反映 register/update/remove/arrange。
- 脚手架可直接生成这个最小单测：`pi-web-ui plugin create my-plugin --with-test`（生成 `index.test.mjs`，现有模板默认不变）。
- `sdk/index.mjs` 是静态拷贝：`SDK_VERSION` 与 `plugin-sdk/package.json` 的 version 保持一致；已装插件用 `pi-web-ui plugin upgrade-sdk [id]` 一键刷新拷贝（版本号对不上才拷，无拷贝的插件跳过）。
- mock 只在 SDK 层：不断言、不碰 `server/plugins.ts` 运行时。类型见 `index.d.ts` 的 `MockHost` / `MockHostOverrides` / `createMockHost`。

## 本次 P0 新增速览

| 能力                                                                                             | 服务端                | 视图                            |
| ------------------------------------------------------------------------------------------------ | --------------------- | ------------------------------- |
| `host.fs.stat/mkdir/append/glob`（+ `*Path` 跨目录版）                                           | ✅                    | —                               |
| `kind: "select"` + `options` + `value`，`host.ui.update(id, { value })` 刷新                     | ✅                    | 顶栏/输入框/消息工具条已渲染    |
| `when` 肯定形条件（`file.isDir` / `file.isFile` / `session.isRunning` / `message.hasSelection`） | —                     | 右键菜单现场求值，置灰          |
| `settings` 新增 `secret` 类型（加密存，浏览器只见有无）                                          | ✅                    | 设置表单掩码 + 留空不改         |
| `settings` 的 `select` 支持 `optionsFrom`（宿主现算候选值：`models` / `thinkingLevels`）         | ✅                    | 下拉自动带「跟随全局默认」      |
| `host.llm.complete`（孤立无工具补全，不建对话）                                                  | ✅（要 `llm` 能力族） | —                               |
| `schedule` 持久版（`{id, persistent, catchUp, label}`，落盘+补跑+进后台面板）                    | ✅                    | —                               |
| `host.requestPermission`（动态授权：net 补主机 / llm 限模型，用户逐条确认可记住）                | ✅（基础族须已声明）  | 确认框 + 设置面板「已授权能力」 |

完整契约见 `docs/architecture-plugins.md`。
