refactor(plugin)!: 插件改名 studio、新增 writer、统一本机 stdio 管道
CI / contract (push) Has been cancelled

命名结构(品牌=市场,产品=插件):市场名保持 deta;插件 deta → studio(displayName DETA Studio),新增 writer(DETA Writer)。安装标识 studio@deta / writer@deta;MCP 服务名 studio_mcp / writer_mcp,可同时安装。plugins/deta → plugins/studio(git mv 保留历史),新增 plugins/writer。

连接形态统一为单一本机 stdio 管道:.mcp.json 与 cordis.patch.yml 均声明 stdio → bin/deta-mcp.sh --profile <产品>,并按官方插件写法补 cwd: "."(缺它时 Cwd=- ,相对命令按宿主进程 cwd 解析会静默起不来);启动器新增 --profile 档位(工作区 ~/.deta/<产品>/workspace、工具面缺省 full),运行时按 DETA_SERVER_ROOT → ~/.deta/runtime → 开发检出解析,入口优先打包产物;DETA_TOKEN 可选注入(缺省时云类工具返回 auth_required)。原远程端点声明(含 OAuth/401 段落)从插件移除。

文档与治理:根 README 与两个插件 README 改为单一管道口径;技能 aml-design 对齐本机全量面;新增 scripts/check-plugin-contract.mjs(E1 禁工具清单 / E2 必须给出 list_capabilities / E3 禁解释内部实现 / E4 市场条目一致性 / W1 开发检出依赖)与 .gitea/workflows/ci.yml。版本:studio 0.3.1、writer 0.1.0。
This commit is contained in:
ringo
2026-09-20 11:56:23 +08:00
parent 80fbd162d3
commit 7d3f9e2933
28 changed files with 1354 additions and 418 deletions
+39
View File
@@ -0,0 +1,39 @@
{
"name": "studio",
"version": "0.3.1",
"description": "DETA Studio 插件:以 AML 为唯一事实源编辑建筑模型,编译为建筑模型并执行规则校验。",
"author": {
"name": "DETA",
"url": "https://deta.city"
},
"homepage": "https://deta.city",
"license": "Proprietary",
"keywords": [
"architecture",
"aml",
"building-performance",
"energy",
"bim",
"design"
],
"skills": "./skills/",
"interface": {
"displayName": "DETA Studio",
"shortDescription": "AML 建筑模型读写、编译与校验",
"longDescription": "在编码代理中直接操作建筑模型:读取与编辑 AML 设计源码,经编译器编译为建筑模型,执行语法与规则校验、围护结构统计与空间邻接查询。AML 是建筑语义的唯一事实源,所有几何编辑均落到 AML 文本。",
"developerName": "DETA",
"category": "Education & Research",
"capabilities": [
"Interactive",
"Read",
"Write"
],
"brandColor": "#2F6F4E",
"websiteURL": "https://deta.city",
"defaultPrompt": [
"读取当前 AML 设计源码并说明建筑构成。",
"在 AML 中修改指定空间的层高,然后重新编译校验。",
"统计当前模型的围护结构面积与外窗面积。"
]
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"mcpServers": {
"studio_mcp": {
"type": "stdio",
"command": "./bin/deta-mcp.sh",
"cwd": ".",
"args": ["--profile", "studio"],
"default_tools_approval_mode": "approve",
"omit_tools_from": ["deferred"]
}
}
}
+175
View File
@@ -0,0 +1,175 @@
# DETA Studio 插件(Codex 插件 / dsh 组合包)
DETA 领域能力(AML 建筑模型读写、编译校验、围护结构统计、空间邻接查询)
以**本机 MCP 服务**形态挂进 Agent harness:同一个目录既是 Codex 插件根,
也是 dsh 组合包根,两端共享 `skills/` 与**同一个启动器**。
- 发行标识:**`studio@deta`**(插件 `studio` + 市场 `deta`)
- MCP 服务名:**`studio_mcp`**(工具前缀 `mcp__studio_mcp__…`)
- 产品档位:启动器 `--profile studio`(工作区缺省 `~/.deta/studio/workspace`)
## 连接形态与凭据(唯一管道:本机 stdio)
| 项 | 值 |
|----|-----|
| 形态 | **本机 stdio**:宿主拉起 `bin/deta-mcp.sh --profile studio`,由它解析领域服务运行时与工作目录 |
| MCP 服务名 | `studio_mcp`(工具前缀 `mcp__studio_mcp__…`);其他产品用各自服务名,可同时安装 |
| 宿主声明 | Codex 读 `.mcp.json`;dsh 读 `cordis.patch.yml`——两边都只声明"调用同一个脚本、带哪个档位" |
| 工作目录 | `DETA_WORKSPACE` → `ANT_HEADLESS_WORKSPACE` → 缺省 `~/.deta/studio/workspace`(启动器创建并在 stderr 回显) |
| 运行时 | `DETA_SERVER_ROOT` → `~/.deta/runtime` → 本机开发检出(回落到开发检出时启动器打印提示) |
| 云侧能力(可选) | `DETA_TOKEN`(`node bin/deta-login.mjs` 获取):项目/楼栋列表、`push_project` / `pull_project` |
| Studio 执行器中继(可选) | `ANT_WORKSPACE_BRIDGE_URL` + 项目 ID(三要素齐备才启用;供模拟/画布类工具) |
**本机形态不做 MCP 鉴权**(进程由宿主或用户在本机拉起,没有 OAuth、没有 401、没有令牌续期)。
未注入 `DETA_TOKEN` 时,云类工具返回 `auth_required`,其余工具(编辑、编译、校验、统计、
脚本、模拟)照常工作——这是与远程形态**同一份工具实现**的不同输入,不是两套代码。
## 工具面:编辑归宿主,MCP 只做引擎与同步
AML 是纯文本 DSL,读写、搜索、重构一律由**宿主原生的文件工具**完成
(Codex 的 read / patch / grep / shell;dsh 的同名能力),MCP 不是编辑入口:
| 分工 | 谁做 |
|------|------|
| 读取 / 修改 / 搜索 `aml.xml`、`bss.md`、`rules.yml`、`behaviors.yml`、`design-brief.md` | 宿主原生工具(本地项目目录里的文件) |
| 编译、规范校验、规则检查、围护结构统计、空间邻接、领域脚本 | MCP 引擎工具(编译 / 校验 / 统计 / 邻接 / 脚本等) |
| 改动送进引擎工作区、取回工作区 | MCP 同步工具 |
本机形态为**全量工具面**(含工作区文件类工具)。**当前可用的完整工具名以运行时
`list_capabilities` 为准**,本仓文档不列举工具清单。
## 本地项目目录约定
一个 DETA 项目就是一个普通目录;引擎工具读的是**引擎工作区**,因此改动要先同步:
```
my-project/
├── deta.json # 可选:项目绑定({ "projectId": "...", "name": "..." })
├── aml.xml # 必需:建筑模型唯一事实源
├── bss.md # 可选:样式/构造附属文档
├── rules.yml # 可选:校验规则
├── behaviors.yml # 可选:声明式行为规则
└── design-brief.md # 可选:设计任务书
```
- 想让引擎直接以项目目录为工作区:启动宿主前设 `DETA_WORKSPACE=<项目目录>`。
- `deta.json` 里的 `projectId` 是 deta.city 云项目 ID;带上它的同步调用会把同一个 ID
绑定到引擎工作区,之后生成/拉取会带同一个 Studio 深链 `https://deta.city/design/<projectId>`。
- 文件名沿用引擎侧约定(`aml` ↔ `aml.xml`、`brief` ↔ `design-brief.md` 等),
本地目录与引擎工作区结构一致,拉取结果可直接落盘回本地目录。
## 端到端工作流:本地编辑 → 同步 → 校验 → Studio 查看
```text
1. 本地编辑 read/patch/grep 直接改 aml.xml(MCP 不参与编辑)
2. 同步 把改动送进引擎工作区(带 projectId 时同时绑定云项目)
→ { ok, files, projectId, studioUrl: "https://deta.city/design/<ID>" }
3. 引擎校验 编译 AML 为建筑模型(工作区最新版本)→ 语法/几何/规则校验逐条修复
围护结构统计与空间邻接查询按需调用
4. Studio 查看 在宿主内置浏览器面板打开 studioUrl;已有相同 URL 的标签优先复用,
不为同一项目重复开新标签,也不要 goto 重载已有标签
5. 跑模拟 在面板里的 Studio 界面运行——模拟由 Studio 执行,本插件 MCP 不替代它
6. 反向核对 从引擎工作区拉回文件(工作区为空时返回空集)
```
路径安全:同步只接受工作区相对路径,绝对路径与 `..` 穿越会被拒绝,单次载荷上限 1 MiB。
## 安装(Codex)
```bash
# 1. 添加市场并安装插件
codex plugin marketplace add https://git.deta.city/deta/deta-agent-plugins.git
codex plugin add studio@deta
# 2. 准备领域服务运行时(二选一)
# a) 本机引擎包已安装 → 启动器自动找到(~/.deta/runtime)
# b) 从源码构建:在领域服务仓库执行 npm run build:mcp,并让 DETA_SERVER_ROOT 指向它
#
# 冒烟(应等待 stdin/stdout):
bash <插件目录>/bin/deta-mcp.sh --profile studio
```
装完即用,**没有 OAuth 登录步骤**。需要云侧能力(项目列表 / 同步)时再注入令牌:
```bash
node bin/deta-login.mjs # 登录 deta.city,写 ~/.deta/credentials.json 与 ~/.deta/env
source ~/.deta/env # 让 DETA_TOKEN 进入宿主进程环境
```
### 登录助手(云侧能力用)
```bash
node bin/deta-login.mjs # 交互式:读标识与密码,写 ~/.deta/*
node bin/deta-login.mjs -u <用户名|已验证邮箱|已验证手机号> # 免交互输入标识
node bin/deta-login.mjs --email-code <邮箱> # 邮箱验证码登录(未设密码的账号用)
node bin/deta-login.mjs --base-url https://<host> # 自建/预发环境
```
**标识(identifier)填门户登录页上用的那一个**:用户名、已验证邮箱、已验证手机号,服务端都会解析到同一账号。
助手先匿名调用 `/login/check` 预检标识是否已注册,再走 `/login` 做密码校验(不走门户的「登录即注册」端点,
不会误建账号)。失败时按服务端提示给出下一步:
| 服务端提示 | 含义与处理 |
|------------|------------|
| `该标识在 <host> 未注册` | 标识不是门户上用的那一个;邮箱验证码注册的账号标识填邮箱 |
| `用户不存在/密码错误` | 服务端不区分「账号不存在」与「密码不匹配」;已确认标识存在,即密码不符,或改用 `--email-code` |
| `该邮箱/手机号尚未验证…` | 先用验证码方式登录一次门户完成验证 |
| `该登录方式未开放` | 用 `GET <base>/prod-api/login/methods` 查当前启用项 |
## 安装(dsh)
```bash
export DETA_PLUGIN_ROOT=<本包所在目录> # 配置层按它定位启动器
export DETA_WORKSPACE=<你的项目目录> # 可选;缺省 ~/.deta/workspace
dsh plugin --profile <name> add <本包>
```
`cordis.patch.yml` 声明本机 stdio,经 `bash -lc` 调用同一个 `bin/deta-mcp.sh`;
未设置 `DETA_PLUGIN_ROOT` 时启动器明确报错退出,不会静默失败。
## 文件
```
.codex-plugin/plugin.json Codex 插件清单
.mcp.json Codex MCP 声明(stdio → ./bin/deta-mcp.sh)
package.json dsh 组合包声明(dsh.bundle.patch)
cordis.patch.yml dsh 配置层(同一启动器,stdio)
bin/deta-login.mjs 登录助手(写 ~/.deta/credentials.json + ~/.deta/env,供云侧能力用)
bin/deta-mcp.sh 本机 stdio 启动器(两宿主共用;解析运行时与工作目录)
README.md 本文件(安装 / 连接 / 排障)
skills/aml-design/SKILL.md 宿主侧技能(工具使用规则;不含工具清单)
```
## 已知缺陷(实测,2026-09-10)
**上游:编码代理 × 模型服务 命名空间重复**
会话累积较多本插件工具调用后,代理会在请求里重复声明 MCP 命名空间,模型服务的
Responses 兼容层拒绝请求:
```
Duplicate namespace name 'deta'; Namespace names must be unique.
```
复现记录(同一插件、不同会话长度):
| 代理版本 | 会话规模 | 触发位置 |
|---------|---------|---------|
| 0.144.4 | 一步并发 2 次调用 | input[10]、input[18] |
| 0.144.4 | 串行 7–8 次调用 | input[37]、input[47] |
| 0.154.0 | 短会话 5 工具 | 未触发 |
| 0.154.0 | 长会话(≈15+ 工具) | input[260] |
已排除/无效的手段:`wire_api = "chat"`(该版本已移除,仅支持 responses)、
`features.non_prefixed_mcp_tool_names`(仅去掉命名空间前缀,重复依旧)、
`supports_parallel_tool_calls = false`(削弱并发触发面,长会话仍复现)。
**应对**:完成一个任务后另起会话;撞到该错误不要重试,重开会话即可(工作区文件保留)。
## 已修缺陷(实测)
| 现象 | 根因 | 修复 |
|------|------|------|
| 写类工具返回 `user cancelled MCP tool call` | MCP 工具默认需审批,非交互模式下无法弹窗 | `.mcp.json` 声明 `default_tools_approval_mode: "approve"`(实测 `auto`/`writes` 都会被取消,只有 `approve` 放行) |
| 本机 stdio 形态起不来:`ERR_UNSUPPORTED_DIR_IMPORT` | 启动器指向未打包的产物,依赖包的目录 import 在 Node ESM 下非法 | 领域服务提供打包入口;启动器优先取打包产物,回落未打包产物时打印警告 |
| 一个插件两种形态并存(远程 URL + 本机 stdio) | 历史遗留:远程端点声明与本地启动器同时存在,语义冲突 | 统一为**本机 stdio 单一管道**,远程端点声明从插件移除 |
+368
View File
@@ -0,0 +1,368 @@
#!/usr/bin/env node
/**
* DETA 登录助手(Node ESM,零第三方依赖)
*
* 用 deta.city 账号换取 API 令牌,供 MCP 远程端点鉴权使用:
*
* node bin/deta-login.mjs [--identifier <用户名|邮箱|手机号>] [--base-url https://deta.city]
* node bin/deta-login.mjs --email-code <邮箱> # 邮箱验证码登录(无密码账号用)
*
* 标识(identifier)就是你在门户登录页填的那一个:
* 用户名 / 已验证邮箱 / 已验证手机号,服务端都会解析到同一账号。
* 注意 deta.city 门户的密码登录端点与 RuoYi 内置的 /login 不是同一个,
* 本助手统一走 /login(已存在账号的密码校验),不会触发「登录即注册」。
*
* 流程:
* 1. 读入标识;先匿名调用 /login/check 预检该标识是否已注册;
* 2. 读入密码(不回显);POST <base>/prod-api/login { username: 标识, password };
* 3. 成功 → 写 ~/.deta/credentials.json 与 ~/.deta/env(均 0600);
* 4. 失败 → 打印服务端 msg 与针对性提示,退出码非 0。
*
* 凭据纪律:token 只写在本次 stdout 与上述两个 0600 文件里,
* 不写日志、不写 stderr、不写其它位置。
*/
import { mkdir, writeFile, chmod } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
const DEFAULT_BASE_URL = 'https://deta.city';
const REQUEST_TIMEOUT_MS = 20_000;
function fail(message) {
console.error(`[deta-login] ${message}`);
process.exit(1);
}
const USAGE = [
'用法:node bin/deta-login.mjs [选项]',
' -u, --identifier <值> 登录标识(用户名/已验证邮箱/已验证手机号);省略则交互式输入',
' --email-code <邮箱> 用邮箱验证码登录(门户只用验证码登录、未设密码时用)',
' --base-url <url> 服务地址,缺省 https://deta.city',
' -h, --help 显示本帮助',
].join('\n');
/** 解析命令行;缺省 baseUrl 取 DETA_BASE_URL 或 https://deta.city,末尾斜杠去掉 */
function parseArgs(argv) {
const options = {
baseUrl: process.env.DETA_BASE_URL || DEFAULT_BASE_URL,
identifier: '',
emailCode: '',
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
const takeValue = (name) => {
const next = argv[i + 1];
if (!next) fail(`${name} 缺少取值`);
i++;
return next;
};
if (arg === '-u' || arg === '--identifier') {
options.identifier = takeValue(arg);
} else if (arg.startsWith('--identifier=')) {
options.identifier = arg.slice('--identifier='.length);
} else if (arg === '--email-code') {
options.emailCode = takeValue(arg);
} else if (arg.startsWith('--email-code=')) {
options.emailCode = arg.slice('--email-code='.length);
} else if (arg === '--base-url') {
options.baseUrl = takeValue(arg);
} else if (arg.startsWith('--base-url=')) {
options.baseUrl = arg.slice('--base-url='.length);
} else if (arg === '-h' || arg === '--help') {
console.log(USAGE);
process.exit(0);
} else {
fail(`未知参数:${arg}\n${USAGE}`);
}
}
options.baseUrl = options.baseUrl.trim().replace(/\/+$/, '');
options.identifier = options.identifier.trim();
options.emailCode = options.emailCode.trim();
return options;
}
/**
* 读一行输入。hidden=true 时不回显(密码);
* 非 TTY(管道/CI)时同样按行读取,便于自动化。
*/
let pendingInput = '';
/**
* 读一行输入。hidden=true 时不回显(密码);非 TTY(管道/CI)时同样按行读取。
*
* 跨读取缓冲:`pendingInput` 保存上一次读取切剩的数据。TTY 下在 raw mode 逐字符处理,
* 遇到换行即切行并把该 chunk 换行之后的字符存回 `pendingInput`——因此
* 一次粘贴「标识 + 密码」两行不会丢第二行。
*/
function readLine({ hidden }) {
return new Promise((resolve, reject) => {
const stdin = process.stdin;
const isTTY = Boolean(stdin.isTTY);
let buffer = pendingInput;
pendingInput = '';
let settled = false;
/** 从 buffer 切出一整行(含行尾判定),并把剩余数据留到 pendingInput;无完整行返回 null */
const splitLine = () => {
const index = buffer.search(/[\r\n]/);
if (index < 0) return null;
const line = buffer.slice(0, index);
const skip = buffer[index] === '\r' && buffer[index + 1] === '\n' ? 2 : 1;
pendingInput = buffer.slice(index + skip);
buffer = '';
return line;
};
const cleanup = () => {
stdin.removeListener('data', onData);
stdin.removeListener('end', onEnd);
stdin.removeListener('error', onError);
if (isTTY && typeof stdin.setRawMode === 'function') stdin.setRawMode(false);
stdin.pause();
};
const finish = (value) => {
if (settled) return;
settled = true;
cleanup();
// TTY 下 raw mode 不回显回车,这里补一个换行;非 TTY(管道)不额外输出
if (isTTY) process.stdout.write('\n');
resolve(value);
};
const onData = (chunk) => {
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
if (!isTTY) {
// 非交互:不回显,按行切分,剩余数据留给下一次读取
buffer += text;
const line = splitLine();
if (line !== null) finish(line);
return;
}
const chars = [...text];
for (let i = 0; i < chars.length; i++) {
const ch = chars[i];
if (ch === '\n' || ch === '\r') {
// 本 chunk 换行符之后的字符属于后续读取(一次粘贴多行):存入 pendingInput 后立即返回
let next = i + 1;
if (ch === '\r' && chars[next] === '\n') next++; // CRLF 视作一个行尾
const rest = chars.slice(next).join('');
if (rest) pendingInput = rest + pendingInput;
return finish(buffer);
}
if (ch === '\u0003') {
// Ctrl-C
process.stdout.write('\n');
process.exit(130);
}
if (ch === '\u0004') return finish(buffer); // Ctrl-D
if (ch === '\u007f' || ch === '\b') {
if (buffer && !hidden) process.stdout.write('\b \b');
buffer = buffer.slice(0, -1);
continue;
}
buffer += ch;
if (!hidden) process.stdout.write(ch);
}
};
const onEnd = () => finish(buffer);
const onError = (err) => {
if (settled) return;
settled = true;
cleanup();
reject(err);
};
// 缓冲里已有完整行(上一次读取的遗留)→ 立即返回,不等待输入
const buffered = splitLine();
if (buffered !== null) {
if (isTTY) process.stdout.write('\n');
resolve(buffered);
return;
}
if (isTTY && typeof stdin.setRawMode === 'function') stdin.setRawMode(true);
stdin.resume();
stdin.on('data', onData);
stdin.on('end', onEnd);
stdin.on('error', onError);
});
}
/** 单引号安全转义,供 `export DETA_TOKEN='...'` 使用 */
function shellSingleQuote(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`;
}
async function writePrivateFile(filePath, content) {
await writeFile(filePath, content, { mode: 0o600 });
// 已存在的文件不被 mode 改写,显式收紧
await chmod(filePath, 0o600).catch(() => undefined);
}
/** 登录标识类型判定(与服务端 LoginIdentifierResolver 的规则一致) */
const PHONE_REGEX = /^1[3-9]\d{9}$/;
const EMAIL_REGEX = /^[\w.+-]+@[\w-]+(\.[\w-]+)+$/;
function identifierField(identifier) {
if (EMAIL_REGEX.test(identifier)) return 'email';
if (PHONE_REGEX.test(identifier)) return 'phone';
return 'username';
}
/** POST JSON,返回 { response, payload }(响应体非 JSON 时 payload 为 null) */
async function postJson(baseUrl, path, body) {
let response;
try {
response = await fetch(`${baseUrl}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch (err) {
fail(`无法连接 ${baseUrl}${path}(${err instanceof Error ? err.message : String(err)})`);
}
let payload = null;
try {
payload = await response.json();
} catch {
payload = null;
}
return { response, payload };
}
function serverMessage(payload, response) {
return (payload && typeof payload.msg === 'string' && payload.msg) || `请求失败(HTTP ${response.status})`;
}
/**
* 匿名查询标识是否已注册;接口异常返回 null(不据此阻断登录)。
* 只填标识所属类型的那一个字段:服务端按 phone → email → username 取第一个非空字段。
*/
async function accountRegistered(baseUrl, identifier) {
const { response, payload } = await postJson(baseUrl, '/prod-api/login/check', {
[identifierField(identifier)]: identifier,
});
if (!response.ok || !payload || payload.code !== 200) return null;
return payload.registered === true;
}
/** 服务端提示 → 可执行的下一步 */
function loginHint(message, identifier) {
if (message.includes('用户不存在/密码错误')) {
return [
`该提示不区分「账号不存在」与「密码不匹配」(已确认 ${identifier} 是已注册标识,故为密码不符或标识与账号不对应)。`,
'请确认:',
' 1) 标识与门户登录页填写的一致(用户名 / 已验证邮箱 / 已验证手机号);',
' 2) 密码与门户一致(区分大小写,注意首尾空格);',
' 3) 若门户只用邮箱验证码登录、从未设过密码,改用 --email-code <邮箱>。',
].join('\n ');
}
if (message.includes('尚未验证')) {
return '该邮箱/手机号在账号里尚未标记为已验证:请先用验证码方式登录一次门户完成验证,再重试。';
}
if (message.includes('停用')) return '账号已被停用,请联系管理员。';
if (message.includes('未开放')) {
return '该登录方式未开放:改用已启用的方式(GET /prod-api/login/methods 可查当前启用项)。';
}
return '';
}
/** 邮箱验证码登录:发码 → 读入验证码 → 校验 */
async function loginByEmailCode(baseUrl, email) {
const send = await postJson(baseUrl, '/prod-api/login/email/send-code', { email });
if (!send.response.ok || send.payload?.code !== 200) {
fail(`发送验证码失败:${serverMessage(send.payload, send.response)}`);
}
process.stdout.write('邮箱验证码:');
const code = (await readLine({ hidden: false })).trim();
if (!code) fail('验证码不能为空');
const { response, payload } = await postJson(baseUrl, '/prod-api/login/email', { email, code });
const token = typeof payload?.token === 'string' ? payload.token : '';
if (!response.ok || !token || (typeof payload?.code === 'number' && payload.code !== 200)) {
fail(`${serverMessage(payload, response)}\n ${loginHint(serverMessage(payload, response), email)}`.trimEnd());
}
return payload;
}
/** 写 ~/.deta/credentials.json 与 ~/.deta/env(均 0600);token 只在 stdout 出现一次 */
async function writeCredentials(baseUrl, identifier, payload) {
const token = payload.token;
const loginUsername =
typeof payload.username === 'string' && payload.username ? payload.username : identifier;
const dir = join(homedir(), '.deta');
await mkdir(dir, { recursive: true, mode: 0o700 });
await chmod(dir, 0o700).catch(() => undefined);
const credentials = {
token,
username: loginUsername,
baseUrl,
loginAt: new Date().toISOString(),
};
const credentialsPath = join(dir, 'credentials.json');
const envPath = join(dir, 'env');
await writePrivateFile(credentialsPath, `${JSON.stringify(credentials, null, 2)}\n`);
await writePrivateFile(
envPath,
`# DETA MCP 凭据(由 bin/deta-login.mjs 生成,勿提交版本库)\n` +
`# 用法:source ~/.deta/env\n` +
`export DETA_TOKEN=${shellSingleQuote(token)}\n`,
);
console.log(`DETA_TOKEN=${token}`);
console.log(`✅ 登录成功:${loginUsername} @ ${baseUrl}`);
console.log(`凭据已写入 ${credentialsPath}(0600),环境变量已写入 ${envPath}(0600)`);
console.log(
`使用:source ~/.deta/env 后再启动 harness(DETA_TOKEN 随环境传给 https://deta.city/mcp-api/mcp)`,
);
}
async function main() {
const { baseUrl, identifier: presetIdentifier, emailCode } = parseArgs(process.argv.slice(2));
process.stdout.write(`DETA 登录(${baseUrl})\n`);
if (emailCode) {
process.stdout.write(`邮箱验证码登录:${emailCode}\n`);
return writeCredentials(baseUrl, emailCode, await loginByEmailCode(baseUrl, emailCode));
}
let identifier = presetIdentifier;
if (identifier) {
process.stdout.write(`登录标识:${identifier}\n`);
} else {
process.stdout.write('登录标识(用户名/已验证邮箱/已验证手机号):');
identifier = (await readLine({ hidden: false })).trim();
}
if (!identifier) fail('登录标识不能为空');
// 匿名预检:区分「标识未注册」与「密码不匹配」,避免密码错时给出误导性提示
if ((await accountRegistered(baseUrl, identifier)) === false) {
fail(
`该标识在 ${baseUrl} 未注册:${identifier}\n` +
' 请填门户登录页上实际使用的标识(用户名 / 已验证邮箱 / 已验证手机号)。\n' +
' 邮箱验证码注册的账号,标识请填邮箱。',
);
}
process.stdout.write('密码:');
const password = await readLine({ hidden: true });
if (!password) fail('密码不能为空');
const { response, payload } = await postJson(baseUrl, '/prod-api/login', {
username: identifier,
password,
});
const token = typeof payload?.token === 'string' ? payload.token : '';
if (!response.ok || !token || (typeof payload?.code === 'number' && payload.code !== 200)) {
const message = serverMessage(payload, response);
const hint = loginHint(message, identifier);
fail(hint ? `${message}\n ${hint}` : message);
}
return writeCredentials(baseUrl, identifier, payload);
}
main().catch((err) => {
fail(err instanceof Error ? err.message : String(err));
});
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# DETA 产品插件的本机 MCP stdio 启动器(唯一入口,多产品共用)
#
# 两个宿主(Codex 插件 / dsh 组合包)都通过本脚本拉起同一个领域服务;
# 产品差异只由 --profile 决定(工作区默认位置、工具面),宿主侧只声明"调哪个脚本、带哪个档位"。
#
# 用法:deta-mcp.sh [--profile <studio|writer>]
#
# 依赖:node + 已构建的领域服务运行时
#
# 环境变量:
# DETA_SERVER_ROOT 领域服务运行时根目录(未设时自动查找,见下)
# DETA_WORKSPACE AML 工作目录(引擎侧工作区真值;缺省 ~/.deta/<profile>/workspace)
# ANT_HEADLESS_WORKSPACE 同上(兼容旧名,DETA_WORKSPACE 优先)
# DETA_TOKEN deta.city 门户令牌(可选;不注入则云类工具返回 auth_required)
# ANT_WORKSPACE_BRIDGE_URL 网关基址(可选;与项目 ID 一起启用 Studio 执行器中继)
# DETA_SURFACE 工具面覆盖(缺省 full)
set -euo pipefail
# ── 产品档位:宿主在声明里传 --profile;缺省 studio ──
PROFILE="${DETA_PROFILE:-studio}"
while [ $# -gt 0 ]; do
case "$1" in
--profile) PROFILE="${2:?--profile 需要一个值}"; shift 2 ;;
*) break ;;
esac
done
# ── 运行时解析:显式 DETA_SERVER_ROOT → 本机引擎包安装位置 → 本机开发检出(打印提示)──
ROOT="${DETA_SERVER_ROOT:-}"
if [ -z "$ROOT" ]; then
for cand in "$HOME/.deta/runtime" "$HOME/Dev/ant/ant-copilot"; do
if [ -f "$cand/dist/deta-mcp.mjs" ] || [ -f "$cand/dist/src/headless/mcp-server.js" ]; then ROOT="$cand"; break; fi
done
fi
if ! command -v node >/dev/null 2>&1; then
echo "[deta] 未找到 node,请先安装 Node.js" >&2
exit 1
fi
# 入口优先取 esbuild 打包形态:tsc 产物在 Node ESM 下会因依赖包的目录 import 直接失败
ENTRY=""
if [ -n "$ROOT" ]; then
if [ -f "$ROOT/dist/deta-mcp.mjs" ]; then
ENTRY="$ROOT/dist/deta-mcp.mjs"
elif [ -f "$ROOT/dist/src/headless/mcp-server.js" ]; then
ENTRY="$ROOT/dist/src/headless/mcp-server.js"
echo "[deta] 警告:回落到未打包产物,请先在该运行时目录执行 npm run build:mcp" >&2
fi
fi
if [ -z "$ENTRY" ]; then
echo "[deta] 未找到领域服务运行时(已尝试:DETA_SERVER_ROOT、\$HOME/.deta/runtime、\$HOME/Dev/ant/ant-copilot)" >&2
echo "[deta] 请安装本机引擎包,或设置 DETA_SERVER_ROOT 指向已构建的领域服务仓库" >&2
exit 1
fi
if [ "$ROOT" = "$HOME/Dev/ant/ant-copilot" ]; then
echo "[deta] 提示:使用本机开发检出 ${ROOT}(非打包运行时)" >&2
fi
# ── 工作目录:宿主不会传项目目录(cwd 通常是插件根),按产品档位定稳定位置 ──
WS="${DETA_WORKSPACE:-${ANT_HEADLESS_WORKSPACE:-$HOME/.deta/$PROFILE/workspace}}"
mkdir -p "$WS"
export ANT_HEADLESS_WORKSPACE="$WS"
# ── 工具面:产品档位缺省全量面;DETA_SURFACE 可覆盖(如 engine)──
export DETA_TOOL_SURFACE="${DETA_SURFACE:-full}"
echo "[deta] 档位=${PROFILE} 运行时=${ROOT} 工作区=${WS} 工具面=${DETA_TOOL_SURFACE} 云令牌=$([ -n "${DETA_TOKEN:-}" ] && echo 已注入 || echo 未注入)" >&2
exec node "$ENTRY" "$@"
+33
View File
@@ -0,0 +1,33 @@
# DETA 组合包(dsh bundle)配置层
#
# 挂载方式:dsh plugin --profile <name> add <本包>
# 本层只做一件事:把 DETA 领域能力作为 MCP 服务挂进 harness。
# 工具以 mcp__deta__<tool> 暴露(与 Codex 侧同名)。
#
# 形态:本机 stdio(唯一管道)——与 Codex 插件共用同一个启动器 bin/deta-mcp.sh,
# 由它解析领域服务运行时(DETA_SERVER_ROOT → ~/.deta/runtime → 本机开发检出)。
# 不校验 deta.city 账号、不隔离工作区;工作目录由 DETA_WORKSPACE 指定
# (缺省 ~/.deta/studio/workspace,实际值见启动器的 stderr 输出)。
#
# 需要云侧能力(项目/楼栋列表、push/pull 同步、Studio 执行器中继)时注入:
# DETA_TOKEN deta.city 门户令牌(node bin/deta-login.mjs 获取)
# ANT_WORKSPACE_BRIDGE_URL 网关基址(与项目 ID 一起构成 host-call 中继三要素)
#
# 前置:设置 DETA_PLUGIN_ROOT 指向本包所在目录(未设置时启动器明确报错退出)。
#
- insert:
- id: deta-mcp
name: '@deepseek-ai/dsh-mcp-client'
config:
serverName: studio_mcp
transport: stdio
command: bash
args:
- -lc
- 'exec "${DETA_PLUGIN_ROOT:?请设置 DETA_PLUGIN_ROOT 指向 DETA 插件目录}/bin/deta-mcp.sh" --profile studio'
env:
DETA_PLUGIN_ROOT: !!js process.env.DETA_PLUGIN_ROOT ?? ''
DETA_TOKEN: !!js process.env.DETA_TOKEN ?? ''
DETA_WORKSPACE: !!js process.env.DETA_WORKSPACE ?? ''
toolCallTimeoutMs: 600000
failOnStartupError: true
+13
View File
@@ -0,0 +1,13 @@
{
"name": "deta-studio-plugin",
"version": "0.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "deta-studio-plugin",
"version": "0.3.1",
"license": "Proprietary"
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "deta-studio-plugin",
"version": "0.3.1",
"description": "DETA Studio:AML 建筑模型读写与编译校验(Codex 插件 / dsh 组合包 双壳)",
"type": "module",
"license": "Proprietary",
"files": [
"cordis.patch.yml",
"skills",
".codex-plugin",
".mcp.json",
"bin",
"README.md"
],
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
}
}
}
+199
View File
@@ -0,0 +1,199 @@
---
name: aml-design
description: 以 AML 为唯一事实源读写建筑模型并编译校验。当用户要求创建/修改建筑方案、房间布局、层高、开洞、围护结构或做建筑性能相关的模型准备时使用。
---
# DETA 建筑性能设计(AML)
## ★ 先读:工具怎么找、模型怎么改(任意工作目录都适用)
**工具发现规则(重要)**:宿主对插件类 MCP 工具做**懒加载检索**,一次检索只返回少量结果
(实测 `Only 10 shown of 23`)。**搜不到不等于不存在**——不确定有哪些工具时,先调
**`list_capabilities`**(能力目录)拿到当前形态的**完整工具名清单**,再按名字**直接调用**。
**改建筑模型只有一个标准动作**(能力目录里有 `build_model` 时):
`build_model({ aml, projectId })` —— 写服务端工作区 → 编译成建筑模型 → 规范校验 → 写进云项目,
一次调用完成;返回 `gate`(校验是否通过)、编译摘要、校验明细、云工作区 `headSha`。
- **不要**把 AML 拷进 Studio 的代码编辑器手工点编译(那是没有闭环工具时的兜底,不是正常路径);
- **不要**用脚本自绘模型图代替 Studio;
- 校验不通过(`gate: failed`)就按 `validation` 明细改 AML 后重跑,直到通过;
- 冲突(`version_conflict`)说明云工作区被其它会话改过:确认要覆盖才传 `force: true`。
**看模型 / 打开 Studio**:`open_studio` 拿一次性免密链接,再用宿主内置浏览器(`iab`)打开——
不要手工在面板登录,也不要用 playwright 逐步点击。
**执行器在线是模拟类工具的前置条件**:`simulate` / `studio_command` / `query_simulation_db` /
`view_canvas` 的算力在 ant-model 浏览器里的执行器,经网关 host-call 中继干活;项目标签页一关
就离线,工具会 fail fast 报「执行器离线」。`open_studio` 会回报 `executor_online`,遇到离线提示
就再调一次它,不要改用手工登录。
## 何时使用
用户提到建筑方案、房间/空间布局、层高、墙体开洞(门窗)、围护结构、体形系数、
建筑模型校验,或需要为后续能耗/日照/CFD 模拟准备模型时使用本技能。
## 编辑与校验分工(先搞清"哪份文件是真值")
本插件挂的是**本机 MCP**:引擎工作区才是编译/校验/模拟读的真值(启动器 stderr 会回显路径)。
两种用法:
- **工作区 = 项目目录**(启动宿主前设 `DETA_WORKSPACE=<项目目录>`):宿主原生文件工具与 MCP
工作区工具看的是同一份文件,直接改完就能编译,不需要同步步骤。
- **工作区 ≠ 项目目录**(缺省 `~/.deta/workspace`):本地改动要先同步进引擎工作区,
引擎工具读到的才是最新版本。
编辑手段按场景选:纯文本改动优先用宿主原生文件工具(read / patch / grep);需要按元素定向
读写 AML(按空间 ID 改属性、只取某实体块原文)时用 MCP 的工作区工具。编译、规范校验、
规则检查、围护结构统计、空间邻接一律走引擎工具。
当前形态到底有哪些工具、叫什么名字,以运行时调 `list_capabilities` 的结果为准。
| 要做的事 | 用什么 |
|----------|--------|
| 读取 / 修改 / 搜索 AML 与其它设计源文件(`aml.xml`、`bss.md`、`rules.yml`、`behaviors.yml`、`design-brief.md`) | **宿主原生工具**:read / patch / grep / shell(直接改文件,不走 MCP 编辑工具) |
| 云端编译(AML → building.json) | MCP `apply_design` |
| **改模型并让它出现在云项目/Studio(闭环单入口)** | **MCP `build_model`**(写工作区 → 编译 → 校验 → 云同步,一次调用完成) |
| 语法与几何校验 / 规则检查 | MCP `validate_model`(`checks` 一次要齐几何 + issues + rules,三类共用同一次编译) |
| 围护结构统计 | MCP `envelope_stats` |
| 空间邻接、交通空间合并 | MCP `query_spatial_graph`、`apply_design(merge_traffic_spaces=…)` |
| 领域脚本(面积预算 / 轴网 / 布局建议 / 校核) | MCP `run_script`、`run_scripts` |
| 把本地改动送上云端、取回云端工作区 | MCP `push_project`、`pull_project` |
| 语法规则与生成上下文 | MCP `get_aml_schema`、`generate_aml` |
固定顺序(工具面里有 `build_model` 时):**本地写 AML → `build_model` → 看 gate →(Studio 里看效果)**。
`build_model` 一次完成「写服务端工作区 → 编译 → 校验 → 写进云项目工作区」,Studio 里立即可见。
工具面里没有 `build_model`(部分部署形态会裁掉它)时才退化为:本地编辑 → `push_project` → `apply_design` →
`validate_model` → Studio 查看。引擎工具读的是**服务端工作区**的文件,没推送过就读不到你的最新改动。
**工具检索结果条数有限**:一次搜索没看到某个工具,不代表它不存在——先调
`list_capabilities` 拿当前形态的完整工具名,再按名字直接调用。
### 闭环单入口 build_model
```
build_model({ aml?: "<AML 全文>", files?: { "aml.xml": "…" }, projectId?: "24", force?: false })
```
- `aml` 给出则先写入引擎工作区 `aml.xml`;`files` 可一并写其它工作区文件(路径规则同 `push_project`)。
- `projectId` 缺省取工作区 `deta.json` 绑定的云项目;没有绑定就先
`push_project({ files, projectId })` 绑一次。
- 返回:`gate`(`passed` / `failed`)、编译摘要、校验明细、`cloud.headSha`、`studioUrl`。
- `gate: failed` → 按 warning/error 改 AML,再调一次 `build_model`,直到 `passed`。
- `status: conflict`(`version_conflict`)→ 云项目工作区被其它会话改过(例如 Studio 面板里编辑过),
确认要覆盖再传 `force: true` 重跑一次。
- `status: unavailable` → 当前没有云同步通道(未注入云令牌,或该部署形态未接云),改用
`apply_design` + `validate_model`(编译校验不依赖云)。
- **禁止**把 AML 拷进 Studio 的代码编辑器手工点编译:那是没有闭环工具时的兜底,不是正常路径。
### push 与 Studio 深链
- 需要云端校验、或想在 Studio 里看效果时,先
`push_project({ files: { "aml.xml": "<全文>", "rules.yml": "…" }, projectId?: "…" })`。
`files` 至少含 `aml.xml`;路径必须是工作区相对路径(绝对路径、`..` 穿越会被拒绝);
单次载荷上限 1 MiB。传 `projectId` 会同时把本项目绑定到云项目
(写入服务端工作区 `deta.json`,之后 `generate_aml` / `pull_project` 也带同一深链)。
- 返回的 `studioUrl`(`https://deta.city/design/<projectId>`,例:云项目 11 → `https://deta.city/design/11`):
**在宿主右侧的内置浏览器面板打开**(做法见下一节);已有相同 URL 的标签优先复用,不要重复开新标签。
- 反向核对 / 拉回云端工作区用 `pull_project`(返回 `{ files, projectId?, studioUrl? }`,
工作区为空时 `files` 为 `{}`)。
## 在右侧面板打开 Studio,并在其中跑模拟
Studio 就是deta.city 上的建筑性能应用。在 Codex 右侧内置浏览器面板打开它之后,
看模型、跑模拟、看结果图层都在面板里完成;本插件的 MCP 只负责把本地改动推上去,不执行模拟。
打开步骤(用宿主内置浏览器 = browser 插件的 `iab`):
1. **先调 `open_studio` 拿链接并确认执行器上线**(免手工登录)。返回 `url`
(`https://deta.city/mcp-api/portal-session/landing?ticket=…&next=/design/<id>`)、
`studioUrl`、`executor_online`、`expiresInSeconds`(180)。该链接在面板内会自动完成
deta.city 登录并跳到 Studio。
- 典型节奏:`open_studio()` 拿 `url` → 面板打开 → `open_studio({ waitSeconds: 20 })`
确认 `executor_online: true` → 再调 `simulate` / `studio_command` 等执行器侧工具。
- 若返回 `auth_required` → 先让用户完成 MCP 授权(`codex mcp login deta_mcp`)再重试;
若 `url` 报「票据无效或已过期」→ 重新调一次,不要改成手工登录。
2. 按 browser 插件技能(`control-in-app-browser`)的 Bootstrap 初始化,再取内置浏览器句柄:
`const iab = await agent.browsers.get("iab");`,并先读一次 `await iab.documentation()`。
3. 先 `await iab.tabs.list()` 看是否已有该 URL 的标签:**有就复用,不要调 `goto` 重载**
(重载会丢面板里正在进行的操作);没有才 `const tab = await iab.tabs.new(); await tab.goto(url);`
4. 打开后告诉用户「Studio 已在右侧面板打开(项目名/ID)」,并说明模拟在面板里运行。
旧路径(工具面里没有 `open_studio` 时):自己拼 `studioUrl`
(本机项目目录 `deta.json` 的 `projectId`,或 `push_project(…, projectId)` 的返回值,
形如 `https://deta.city/design/<projectId>`),按上面第 2–4 步打开——但需面板已登录。
在面板里跑模拟:
- 模拟入口在 Studio 界面内(分析/模拟面板、GH 模板等)。**默认交给用户在面板里操作**——
模拟参数与结果解读属设计决策,不要代替用户点。
- 用户明确要你代跑时:先 `await tab.ax.write()` 取当前界面元素,再 `await tab.ax.click(<元素序号>)`
逐步操作;每一步后重新取一次状态确认,**不要凭记忆点坐标**。
- 结果出来后用 `await tab.ax.write("both")` 或 `await tab.screenshot()` 确认图层与数值,再向用户汇报。
前置与故障:
- 面板未登录 deta.city:用 `open_studio` 的一次性链接即可免手工登录(见上);只有拿不到
`open_studio` 时才退化为手工:在面板里打开 `https://deta.city/login` 完成一次登录
(登录态写入该浏览器的 `ant_token` cookie 与 `ant-model-auth` localStorage,之后长期有效),
登录完成后重新打开 `studioUrl` 即自动加载项目。
- **宿主把 MCP 授权页开在哪个浏览器由宿主决定**(实测 Codex Desktop 走系统默认浏览器),
且**那一页的登录态不会进入面板**——所以别把"MCP 已授权"当成"面板已登录",两者要分别确认:
MCP 授权只解决 Codex↔服务端;面板登录解决 Studio 在面板里的会话(`open_studio` 正是两者的桥)。
实测口径:授权页在系统浏览器完成换码后停留在宿主的回调页("Authentication complete. You may close
this window."),该页不归我们控制;授权页自身已改为**在隐藏框架内完成换码**并给出「打开 deta.city」
与「关闭本页」,不再顶层跳走。
- 没有 `projectId`:Studio 打开的是空项目。先在门户创建云项目拿到 ID,再 `push_project(…, projectId)` 绑定。
- 宿主没有内置浏览器(CLI 等形态):直接把 `studioUrl` 给用户,让他自己打开,不要假装已经打开。
## 铁律
1. **AML 是建筑语义的唯一事实源**。任何几何或属性变更都必须落到 AML 文本,
不得用中间 JSON 表达修改意图。
2. **禁止凭记忆编写 AML 结构**。动手前必须先取真实语法:
- 已有模型:读工作区里的 `aml.xml`(宿主原生 read 工具,或 MCP 的 `read_source`)
- 语法规则:`get_aml_schema`(从编译服务实时获取,唯一权威)
3. **属性知识不硬编码**:房间用途、构造材料、洞口参数等可选值从工具返回中获取,
不要凭经验列举。
4. **局部修改优于整体重写**:改单个空间属性时只改该空间,不重写整个模型。
5. **控制单会话工具调用量**。编码代理在会话累积较多本插件工具调用后,会在请求里重复
声明 `deta` 命名空间,上游模型服务(Responses 兼容层)直接拒绝:
`Duplicate namespace name 'deta'; Namespace names must be unique`。
实测触发点随会话长度前移(并发调用会显著提前触发)。规程:
- 同一轮只调用一个本插件工具,不要并发;
- 单会话保持轻量,完成任务后另起会话继续;
- 一旦出现该错误,**停止重试并告知用户重开会话**(工作区文件不会丢失)。
6. **复杂形体(`<solid script="geometry/xxx.py">`)的脚本属于工作区文件,不是宿主本地文件**。
编译服务没有工作区访问权:编译前引擎会按 AML 声明的相对路径把脚本内容**随请求携带**
(键名必须与 AML 一字不差,含 `geometry/` 前缀)。因此:
- 脚本必须真实存在于**工作区**(用工作区文件工具读写,如 `geometry/shapes.py`);
- 脚本取不到时编译会**显式失败并列出原因**(而不是"编译通过但形体丢失");
- 改动脚本内容后无需重命名或改 AML;若 AML 里写了 `version="sha256:…"`,脚本内容变了
必须同步更新该版本号,否则编译报「几何脚本版本不匹配」。
## 标准工作流
1. **读**:用宿主原生工具读本地项目里的设计源码(`aml.xml` 等)。模型为空时先用
`generate_aml` 取生成上下文,再用 `get_aml_schema` 核对语法,然后在本地写出 AML。
2. **改**:在本地用 read / patch / grep 直接改 AML 文本(局部修改优于整体重写)。
3. **推**:`push_project({ files: { "aml.xml": … } })` 把改好的源码送到服务端工作区
(要绑云项目就带上 `projectId`)。
4. **编**:`apply_design` 提交编译 → 编译为建筑模型,产物落盘 `building.json`。
5. **校**:`validate_model` 做语法与规则校验,逐条读取错误码并修复,不得跳过;
规则类问题用 `validate_model({ checks: ['issues','rules'] })` 一次查齐(info 级提示只在 issues 视图可见)。
6. **看**:需要几何指标用 `envelope_stats`(围护结构/外窗面积)或 `query_spatial_graph`
(空间邻接与查询);要看模型、**跑模拟**、看结果图层,就在右侧面板打开 `studioUrl`
(见「在右侧面板打开 Studio,并在其中跑模拟」一节),模拟在 Studio 界面里运行。
## 错误处理
- 编译服务未在线时,编译/校验类工具会返回明确错误文本。此时**不要伪造结果**,
直接报告「编译服务未接入」,改用宿主原生文件工具(read / patch)核对与编辑本地 AML。
- 编译报错必须逐条修复后重新 `apply_design`,禁止在报错状态下声称完成。
## 工具名从哪来
本技能**不列工具清单**:工具面随部署形态(引擎形态 / 全量形态)裁剪,任何手写清单都会过期。
不确定有哪些工具时,调 `list_capabilities` 拿当前形态的**完整工具名**,再按名字直接调用;
每个工具能做什么、参数怎么给,以它自身的 description 与返回为准。