@lark-project/cli
Version:
飞书项目插件开发工具
269 lines (180 loc) • 19.6 kB
Markdown
# mode=plan:理解需求 + 交互补全 + 生成配置文件
## 点位类型速查
| 类型 | 一句话用途 |
|------|---------|
| `customField` | 扩展字段类型(飞项一方字段类型的开发者 SDK 版;admin 装上后享筛/排/度量/流转条件/字段配置/布局配置/OpenAPI 读写)|
| `control` | 自定义 UI 组件 / 面板 / 卡片(嵌表格列 / 详情页 / 节点表单 / 新建页;数据自管,平台不存)|
| `liteAppComponent` | 轻应用组件(搭建器拖拽组件) |
| `dashboard` | **工作项详情页 Tab**(绑定具体工作项实例,展示/组合该工作项数据;运行时 namespace 是 `tab`)|
| `button` | 自定义按钮(工作项详情页 / 节点页)|
| `component` | 节点排期组件(`component_type: schedule`;替换空间所有排期入口)|
| `page` | 空间级独立全屏页(顶部/左侧导航扩展页,**不绑工作项**)|
| `view` | 工作项列表自定义视图 |
| `configuration` | 插件级配置入口 |
| `intercept` | 状态流转拦截器(同步返回放行/拒绝)|
| `listen_event` | 工作项事件异步监听(创建/更新/状态变更等)|
> **必填字段 / 长度 / 枚举合法性以 `lpm schema` 输出为准**,由 `local-config set` 强制校验——避免静态镜像随 schema 迭代漂移。
> **平台命名不一致,别被带偏**:类型名 ≠ key 前缀 ≠ 运行时 namespace。`page` 类型 key 前缀却是 `board_`(叫 board 实为导航页,**不是**看板/仪表盘);`dashboard` 类型 key 前缀 `dashboard_`、运行时 namespace 是 `tab`(详情页 Tab)。
> **再分清 key vs resourceId**:点位 **key** 形如 `dashboard_xxxxxx`(无 `_web_`,schema 校验对象);**resourceId** 形如 `dashboard_web_xxxxxx`(带 `_web_` 端标记,是 `platform.web.resourceId.resource` + `src/features/<id>/` 目录名)。文档里满屏的 `board_web_xxx` / `dashboard_web_xxx` 都是 resourceId,**不是 key**。
## P1:识别操作类型 + 粗扫候选点位类型
P1 做两件事:
1. 操作类型识别(增 / 改 / 删)
2. 粗扫候选点位类型——按文件顶部 [点位类型速查](#点位类型速查) 一句话用途表 + 下文 [术语→点位类型映射](#术语点位类型映射组件一词歧义) 表识别(不重述)
**特例**:字段语义需求("字段值是 X" / "挂工作项上的字段")→ candidate 同时含 `customField` + `control`(二选,P2.5 决策树定)。
**特例**:词面歧义触发双候选——用户原话含"组件 / 控件 / 筛选器 / 卡片 / 看板"等点位类别字面通用词时(典型 trap:"筛选器**组件**" 既可 `control` 字段控件又可 `liteAppComponent` 独立组件),P1 必须输出 ≥2 候选,禁止单候选退化;进 P2.5 走 step 3 第三分支(其他双候选)拍板(**不**走 §P2.5.1,§P2.5.1 仅限 customField + control 对)。
**跨点位协作**(详情页表单联动 等)→ 加读 [`point-types/shared-scenes.md`](feature-point-types/shared-scenes.md)。
P1 **不做精细选型**(customField vs control 二选等),精细选型放到 P2.5(schema + candidate index.md 都读完后做)。
用户原话的读取方式:
- **被 workflow phase 编排时**:用 `lpm --cwd "<projectRoot>" ai state get` 取 checkpoint 的 `context.originalRequirement`(输出为空 = 无 checkpoint;Phase 1 尾回录的版本,逐字保留、不受会话压缩影响)
- **独立调用时**:从当前对话上下文读
输出:N 个独立需求 + 各自候选点位列表,等 P2 + P2.5 精细选型。
**MCP 缓存协议**:见 [`shared.md`](shared.md) 「MCP 检索技巧」末尾。`.lpm-cache/` 由 CLI 自动 gitignore + 在 `local-config set` / `publish` 成功后自动清理,AI 只写不清理。
## P2:获取 Schema 和当前完整配置
```bash
lpm --cwd "<projectRoot>" schema
lpm --cwd "<projectRoot>" local-config get --remote
```
- 两条命令都是**文件优先**:CLI 把结果写到 `.lpm-cache/schema/point-schema.json` 和 `.lpm-cache/config/remote.json`,stdout 只回一行相对路径
- **两个命令并行执行**,不要等第一个完成再跑第二个
- 若 `local-config get` 对应的远端为空,`remote.json` 内容是 `{}`,作为后续增改的基础
- 后续所有消费(切片、写 draft、diff 对比)都走文件,**不要整读 JSON 到 context**
### 按点位分片读取 schema
`.lpm-cache/schema/point-schema.json` 约 2400+ 行(~30K tokens),用 `lpm ai peek` 切片:
```bash
# 取某个点位完整定义 + 自动追 $ref(BuilderLayout / PointKey 等子 schema 一次拿全)
lpm --cwd "<projectRoot>" ai peek .lpm-cache/schema/point-schema.json LiteAppComponentPoint
# 选型阶段:列所有点位类型(一句话描述)
lpm --cwd "<projectRoot>" ai peek .lpm-cache/schema/point-schema.json --index
# 只要 description 字段(看 AI 行为指引,忽略 pattern/enum 校验噪音)
lpm --cwd "<projectRoot>" ai peek .lpm-cache/schema/point-schema.json LiteAppComponentPoint --descriptions-only
# 不追 $ref(仅主 schema)
lpm --cwd "<projectRoot>" ai peek .lpm-cache/schema/point-schema.json LiteAppComponentPoint --no-follow
```
`lpm ai peek` 默认行为:结构模式自动取完整子树 + 追 `$ref` 闭包 + 循环检测,不会截断。**只取当前要操作的点位类型**,不要用 `--index` 当整读 schema 的借口。
**`--summary` / `--index` 的使用边界**(防止基于目录级信息下结论):
- `--summary` / `--index` / `--list` 只用于**导航和选型**(决定"我要改哪个点位 / 用哪种类型")
- 一旦确定了目标点位类型或 key,**落笔前必须再 peek 一次到具体符号**取完整定义:
- 写配置 → `lpm --cwd "<projectRoot>" ai peek point-schema.json <PointName>` 取完整字段约束
- 写 key 级改动 → `lpm --cwd "<projectRoot>" ai peek remote.json 'data[point_type=xxx].point_config[key=yyy]'` 取完整当前值
- "我看了 summary 知道有这个点位" ≠ "我知道这个点位的字段长什么样"——前者只够**选**,不够**改**
### 涉及 DSL 字段(table_cell / table_layout)的额外引导
触达以下字段时,schema 给的只是**字段约束**(type enum / style 字段 / 嵌套深度 / event action 枚举 等),**靠约束写不出有意义的 DSL 值**——DSL 的表达式语法(`{{...}}`、`$container` / `$colorTokens` / `$i18n` / `$fieldValue` 等平台变量、`data` 变量和数据接口返回字段的对应、事件 action 的 params 结构、色值多语言 token 的运行时行为)都在 MCP 里。
| 字段 | 归属点位 |
|---|---|
| `platform.web.table_cell` | `control`(scene 包含 1 时必填)|
| `platform.web.table_layout` | `customField`(必填)|
**MUST 先**走 MCP fallback 关键词 `开发表格页控件` / `表格列控件 DSL` 拉原文,按 MCP 缓存协议写到 `.lpm-cache/mcp/table-cell-dsl-spec.md`;**然后**对照 schema 的 `dslSchema` + MCP 里的使用示例 + 用户需求组合出合规 DSL。
- schema 告诉你**什么字段合法**(type 枚举 / style 支持哪些键 / 嵌套深度)
- MCP 告诉你**怎么用才有意义**(表达式语法 / 平台变量含义 / data 变量对接数据接口的链路 / 事件 params 形态)
**冲突裁决**:schema > MCP。字段命名不一致时以 schema 为准(例:schema 写 `$colorTokens.light`/`dark`,MCP 原文示例曾拼作 `lignt_mode`/`dark_mode`;写配置时用 schema 的拼写)。
**Mustache 表达式的能力边界**:schema 和 MCP 都只明确列出 5 类操作符(算术 `+-*/%` / 比较 `== != >= <= > <` / 三元 `x?y:z` / 属性访问 `x.y` / 数组访问 `a[i]`);**调 JS 方法**(`.toFixed()` / `.length` / `.slice()` / `.toUpperCase()` 等)两边都没说支持——**保守不用**,需要格式化就在**数据接口返回端预处理成字符串**(type: string 变量)或**走 MCP fallback** 进一步确认。
### 按需读取 remote.json
`.lpm-cache/config/remote.json` 是远端全量配置,同样用 `lpm ai peek`:
```bash
# 某类型下的某点位
lpm --cwd "<projectRoot>" ai peek .lpm-cache/config/remote.json 'liteAppComponent[0].key'
# 类型 + key 列表(diff / 删除确认最小视野)
lpm --cwd "<projectRoot>" ai peek .lpm-cache/config/remote.json --index
```
或者 remote.json 文件较小时直接 Read 整文件也 OK(判断标准:空 `{}` 或 <10 个点位时整读)。
### 术语→点位类型映射("组件"一词歧义)
schema 中 `liteAppComponent` 叫"轻应用组件"、`component` 叫"组件位",但用户日常口语里"组件"可以指任何东西。识别规则:**先从用户描述里抽核心名词(拓展字段 / 控件 / 轻应用 / 排期),再映射点位类型。"组件"二字是修饰语,不是判断依据。**
| 用户可能的说法 | 核心名词 | 正确点位类型 | 易错映射 |
|--------------|---------|------------|---------|
| "拓展字段"/"拓展字段组件"/"字段模板"/"自定义字段" | **拓展字段** | `customField` | ~~liteAppComponent~~ |
| "控件"/"控件组件" | **控件** | `control` | ~~liteAppComponent~~ |
| "轻应用组件"/"概览组件"/"构建器组件"/"拖拽组件" | **轻应用/概览/构建器** | `liteAppComponent` | — |
| "排期组件"/"节点排期" | **排期** | `component` | ~~liteAppComponent~~ |
| "详情页 Tab"/"详情页加 Tab"/"标签页"/"详情页区块" | **详情页 Tab** | `dashboard` | ~~page~~(page 是空间级独立页、不绑工作项;详情页 Tab 必须绑工作项实例 → dashboard)|
## P2.5:精细点位选型 + 方案确认
**前置**:P2 已拉 schema + remote.json。
**主公式**(4 步):
1. **Read 候选点位 index.md**:含独立 doc 的 6 类(customField / control / liteAppComponent / dashboard / button / componentSchedule);其余 5 类(page / view / configuration / intercept / listen_event)走 [`code-plan.md`](feature-code-plan.md) Step 2 MCP fallback。**字段语义候选 customField + control 两份必读**。
2. **拉 schema 决策依据**:候选 = customField + control 是**唯一双候选选型边界**——必跑 `lpm --cwd "<projectRoot>" ai peek .lpm-cache/schema/point-schema.json ControlPoint` 拿完整 $comment(含完整能力对比表 + 三层决策树);其他点位单候选选型靠 index.md 即可。
3. **按候选数分支**:
- **单候选** → "已选 [类型],理由:[X]。确认吗?" 等用户确认
- **候选 = customField + control 双候选** → 走 §P2.5.1,**不要走单候选话术**
- **其他双候选**(如 P1 词面歧义触发的 control + liteAppComponent)→ echo 两候选并列:每个写一句话能力 + 利弊 + 倾向理由,等用户答 "确认 / 改 / 不知道";不要单候选话术绕过
4. **用户答完进 P3**——选型不对在这里 catch 比生成 draft 后再发现成本低。
### P2.5.1:customField vs control 选型边界(特例分支)
**触发**:P1 候选**同时含** customField + control 两份。其他点位选型**不进入此节**。
**为什么是特例**:customField vs control 是 schema 里唯一一对"语义重叠 + 多维度可换路径"的点位,需要"列双候选 + 多维度对比 + 用户拍板"。其他点位是单选,不需要这个开销。
**强制动作**(按顺序,每步缺失 = HARD 失败):
1. **echo schema $comment 完整能力对比表**(peek 拿到的内容逐字搬到本 plan 输出)——这是双候选 derive 路径差异的事实依据,不 echo 等于没看
2. **走 schema $comment 三层决策树**(Step 0 字面术语 → Step 1 独家信号 → Step 2 歧义停下问)
3. Step 0/1 命中 → 一行话术告知用户 "已选 [类型],理由:[命中信号原文]。确认吗?" + 等回复
4. **Step 2 命中 → 强制四动作**:
- 扫题面 3 类信号:① 数据来源(CRM / ERP / 不落地)② 数据存储归属(落地飞项 vs 业务后端)③ 数据形态(表格列展示 / 表单交互)——3 类必扫,无命中写"无"。**端支持差异不在此列**(mobile 不展示两路径都能搞定,非真歧义)
- 每条命中信号配 1 个对用户的关键决策问题(不漏)
- 列两候选并列展开(基于完整能力对比表 derive 各自路径下针对本需求的实施 + 代价)
- 全填完才向用户拍板
**铁律**(违反 = HARD):
- 禁止预先表态选型("应选 customField" / "候选点位类型 customField" / "走 control 路径" 都算)
- 必两候选并列展开(单个不算"列")
- 3 类信号必扫完才能拍板
- 拍板前禁止写产物代码
## P3:交互补全缺失字段
**规则:一次只问一个问题**,语义化表达(例:不说"key 是什么",而说"这个点位的唯一标识符打算用什么?建议格式如 my_board_point")。
**外部数据源识别**:补字段时遇用户提到外部系统(CRM / ERP / 自建后端 / oncall 值班人 / 任何非飞项内的数据源)→ stop-and-ask 数据来源:"这个值是飞项内字段还是外部系统?外部需自建后端代理调(前端不裸调外部 API,token 进 bundle = 公开发布凭据)"。**不要硬编默认值或编造 OpenAPI**。
### 对于修改操作
1. `lpm ai peek` 定位目标点位(不要 Read 整文件):`lpm --cwd "<projectRoot>" ai peek .lpm-cache/config/remote.json '<type>[key=<key>]'`
2. 仅询问需要修改的字段
3. 其余字段保持原值
## P4:生成完整配置文件
**draft 不是从零写,而是从远端基线改出来的。** set 是全量替换(规则定义见 [`shared.md`](shared.md) 的"全量提交约束")——draft 必须是「远端全量配置 + 本次改动」,漏写任一现有点位 = 推送时永久删除它。
### P4.0:从远端基线建 draft 起点(不可跳过)
```bash
# 从 remote.json 一步建 draft:自动拼 draft-<ts>.json 文件名 + 路径校验,stdout 回显 draft 路径
DRAFT=$(lpm --cwd "<projectRoot>" ai init-draft)
```
`remote.json` 与 draft 同构(CLI 已反向转换成本地 DSL 形态),`init-draft` 直接把它快照成 draft 起点;远端为空时是 `{}`,draft 也从 `{}` 起。
**这一步让"保留所有现有点位"成为构造的天然结果**——下面的增 / 改 / 删都是**在这份 draft 副本上动具体条目**,不是凭记忆把全量 JSON 重新拼一遍。先判能不能改现有点位满足需求(改它、不新建);只有用户明确要删时,才从副本里移除——减少点位才会触发 A2/set 的删除闸口。
> 改 draft 用 `lpm ai patch-json <draft> --set-path/--add-path/--delete/--merge-path ...`:磁盘层按 path 精准动几处,draft 在盘上保持全量、context 里只装 delta,不必整读整写全量 JSON。下面三类操作即用它落地。
### 添加操作(在副本上)
在对应类型数组中 append 新点位对象,**保留所有其他类型和其他点位不变**。
```
远端: { "page": [A], "button": [B1, B2] }
用户: 新增一个 liteAppComponent
结果: { "page": [A], "button": [B1, B2], "liteAppComponent": [新点位] }
```
对于 Single 类型(page/view/dashboard/config/intercept/listen_event/component),如果远端已有该类型的点位,不能再添加(maxItems=1,schema 会拒绝)。应提示用户是要替换还是修改现有点位。
### 修改操作(在副本上)
找到匹配 key 的点位,合并更新字段,**其余字段和其他点位原样保留**。
```
远端: { "button": [{ "key": "button_x1", "name": "旧名" }, { "key": "button_x2", ... }] }
用户: 把 button_x1 的名字改成"新名"
结果: { "button": [{ "key": "button_x1", "name": "新名" }, { "key": "button_x2", 原样 }] }
```
### 删除操作(在副本上 · CRITICAL · 根原则 2 数据完整性 · config.plan 实施点)
从对应类型数组中移除匹配 key 的点位,**其余原样保留**。
```
远端: { "page": [A], "button": [B1, B2] }
用户: 删除 B2
结果: { "page": [A], "button": [B1] }
用户: 删除整个 button 类型的所有点位
结果: { "page": [A] }
```
(如果只传 `{ "liteAppComponent": [新点位] }` 而不包含 page 和 button,远端的 page 和 button 会被清除。)
**删除确认规则**:draft 相比远端基线减少了任何点位时,先向用户列出被删除的点位清单(key + name)并获得确认,再执行 set——不要静默删除。
### 生成前自检(仅语义级,schema 强校项让 A1 `lpm local-config set` 兜底)
- [ ] **从副本改出**:draft 由 P4.0 的 `remote.json` 副本编辑而来(含远端所有现有类型),非从零拼。只需复核没误删——而非"是否凑齐了全量"
> 其他规则(key 唯一 / Single 类型 maxItems / 必填字段 / 枚举合法 / name 长度 / platform 字段类型限定)schema 强校,A1 阶段 lpm 拒就拒,按 [`config-apply.md`](feature-config-apply.md) §A1「错误分类处理」修复重跑。AI **不要在 P4 复述**——校两次浪费 token。
## P5:判定前端点位 / webhook 形态点位(结构性,下游 stage 用)
从这次 draft(+ 远端基线)的点位 config 里,机械判出两组——下游 Stage Code 用它决定要不要跑、(可能的)后端那一半用它知道要接哪些 webhook 端点:
- **有前端的点位** = 在 `plugin.config.json` 里有 `resource` / `entry` 的点位(渲染点位)。这组非空 → Stage Code 要跑;这组为空 → 这个 feature 没有前端渲染点位,Stage Code 整段跳过。
- **webhook 形态的点位**(= 一定有后端):
| 点位类型 | webhook url/token 在哪 |
|---|---|
| `ai_node` / `ai_field`(`app_type=ai_node` / `ai_field` 工程)| url/token 在一个 `listen_event` extension 里 |
| `intercept` / `listen_event` | 点位顶层有 `url`(+ `token`)|
| `control`(嵌新建页等场景)| 点位顶层 `control[].url` |
`ai_node` 可两半都有(节点卡片 resource + 算结果的 webhook);`ai_field` 通常只后端那半。这组非空 → 这个 feature 需要后端那一半(Stage Config 之后走 feature 末尾的「→ 后端那一半」产交接包)。
webhook 形态点位的 url/token 在 Stage Config 这步可以先填占位 / localhost——真公网地址在后端那一半的联调收口(第 2 次 `local-config set`)才指过去。
### P5.1:后端可行性粗核(可选,命中 webhook 形态点位时)
写前端前可选跑 `lpm perm check --apis <按业务意图粗判的接口中文名>` 核一次:两类工程都极少"做不了"(AI 应用恒 `satisfied`;`normal` 的 `needApply` 可申请、不阻塞),唯一要 catch 的是 `unknown`(接口名写错 → 重查;此 app 确实没有 → 停下告知用户)。完整 perm 核对在「→ 后端那一半(relay)」A2 确认权限就绪,这里不替代。
## P6:输出
plan 阶段完成后输出:
- 生成的配置文件路径:`.lpm-cache/config/draft-{timestamp}.json`
- 变更摘要:添加/修改/删除了哪些点位(类型 + key)
- 前端 / 后端判定:有前端的点位有哪些(→ Stage Code 是否跑)、有 webhook 形态点位吗(→ 是否需要后端那一半)
> 中间产物(`.lpm-cache/config/remote.json` / `draft-*.json` / `schema/point-schema.json` / `mcp/*.md`)由 CLI 在 `local-config set` / `publish` 成功后自动清理,skill 不负责清理。