Compare commits

..
30 Commits
Author SHA1 Message Date
yeuimu 4462475a6f feat(cli): record 写入 --typecast/--order/--field-key-type + 附件字段 URL 导入 skill 文档 (0.2.0) 2026-10-14 00:21:00 +00:00
yeuimu a3fa01e25f docs(skill): plugin bundle pipeline v2 (IIFE/多资产/热更新) 文档入仓 2026-10-14 00:20:56 +00:00
yeuimu c9694511a1 feat(cli): base/space 命令 + 执行守则 + eval harness 入仓 + 多轮 eval (0.1.9)
- 新命令: space list/create、base create/delete(实测通过,冒烟覆盖)
- SKILL.md §0 执行守则:完成全部步骤再答复/不谎报/分批/撤销指引
  (snapshot 无恢复 API,用 deactivate+节点改回)
- hint 补 429 限流与超时模式
- evals/ 入仓: 14+6 题 harness 参数化(EVAL_ROOT/EVAL_ENV/EVAL_MODEL/
  EVAL_CFG/auto_skill 触发模式)+ 多轮修正式 eval 与灵活判分
- 判分器加固: workflow list 错误负载不崩、官方 CLI 探测(command -v)不算使用
- 实测: auto 触发批(入仓版重跑) 11/11、99.0% 首试、100% ≤2 次;
  多轮修正 2/2+3/3(修改而非重建);冒烟 85 用例含 token 路由(低权会话)
- 运维事实入档: POST/DELETE /api/plugin 为管理员专属(PAT 403)
2026-09-30 08:12:16 +00:00
yeuimu 517dde9dee feat(skill): native-form 速查文档 + 路由行 (0.1.8)
- dynamic-plugins/native-form.md: 咨询题直答型(两档注入 API + 多步表单
  copy-paste 示例 + 边界),解决 eval-6 历史失败(agent 把咨询当开发、跑飞)
- SKILL.md 模块地图加 3b 行:问'能不能/怎么做原生表单'先读它,直接作答
- 实测:plugin 技能 N=3 全绿(P1 25/25, P2 25/25, P3 24/25),
  native-form-routing 三轮全过(此前两轮全败)
2026-09-30 07:16:33 +00:00
yeuimu d6a8bc4063 feat(cli): 错误提示行动 hint + 发布门禁 + 分发收尾 (0.1.7)
- 错误输出附 hint: 404 Invalid tableId / Workflow not found / 路由不存在 /
  401 / 403 / 非法选项值 / visibilityFilter 等 9 类模式,JSON 模式含 hint 字段
- prepublishOnly 门禁: build + test + 全命令冒烟(81 用例)
- skills/ 移除 evals/(判分标准不随技能分发) + scripts/sync-skills.sh 一键同步
- 冒烟建库固定在 agent-playground 专用空间(生产保护红线)
2026-09-30 06:19:48 +00:00
yeuimu e584b7ebbe feat(cli): 参数别名收尾 + 全命令冒烟套件 (0.1.6)
- field list/get、view list、preset list 接受 --table-id(位置参数之外)
- record get/list 接受 --take/--skip 别名(= --page-size/--page 的原始偏移)
- scripts/smoke-commands.sh: 全命令冒烟套件,一次性验证所有 CLI 命令
  无幻影路由/无 CLI 语法错/无 CLI 请求形状错(80/80 通过,含 8 种触发器、
  preset/secret/subscription/plugin 只读命令)
- 轨迹依据: iteration-4 中 agent 猜 --table-id/--take 被拒,别名后归零
2026-09-30 03:59:39 +00:00
yeuimu 8dacbf7f2d fix(cli): 轨迹分析驱动的 8 处命令修复 + 技能文档同步 (0.1.5)
- table get/update: 改用真实路由 /api/base/:baseId/table/:tableId(/name)
- view get/update/delete: table 域路由;view update 实现文档承诺的
  --filter/--sort/--group/--column-meta(sort 自动包装 sortObjs)
- record create --fields 自动包装 {records:[{fields}]}
- record delete 单条走单删端点(修复单值 query 解析成 string 的 400),
  支持位置参数;record list 加为 record get 别名
- record get-one / record get --record-id 走 table 域路由
- 移除 sql 命令:后端无 SQL 查询 API(404),技能文档 5 处同步改写
- plugin 技能 dev-workflow 内联 known-good webpack.config
  (禁止 output.library.type=iife,评估轨迹中 with_skill 组也反复踩坑)
2026-09-29 06:45:37 +00:00
yeuimu fbff682d2c docs: 精简 README 安装引导词 2026-09-29 03:30:48 +00:00
yeuimu 8654cd8f20 docs: README 补充 skills CLI 按需单装(--skill)与 PromptScript 无害报错说明(v0.1.3 同步) 2026-09-29 03:14:46 +00:00
yeuimu fbf97a1b46 docs: 安装方式只保留 npm 包,移除 gitea git+ 备选说法
README/SKILL.md 统一为 npm i -g @inkreach/inkreach-teable;
v0.1.2 同步进包
2026-09-29 02:57:21 +00:00
yeuimu 69f354c833 chore: v0.1.1 —— 同步 SKILL.md/README 安装文档进 npm 包 2026-09-29 02:43:45 +00:00
yeuimu 87490e9c6f feat: 发布 npm 公共包 @inkreach/inkreach-teable
- 包名 @inkreach/teable-cli → @inkreach/inkreach-teable(npm i -g 即装即用)
- bin 路径规范化 ./dist/bin.js → dist/bin.js(npm 11 发布校验要求)
- README / SKILL.md 安装命令改为 npm 包优先,gitea git+ 与离线方式降为备选

已发布:https://www.npmjs.com/package/@inkreach/inkreach-teable (v0.1.0)
验证:tarball 99KB/38 文件,registry 全新安装 → bin/版本/真实端点鉴权全通过
2026-09-29 02:22:19 +00:00
yeuimu abdf71a816 feat(skill): inkreach-teable-plugin 按 skill-creator 评估流程迭代优化
- 导入规则精确化:@teable/* 禁运行时值导入(import type 可);
  react 仅在 webpack externals 映射到宿主全局时允许
- 新增效率守则:信任技能文档、知识题不读宿主源码、不上传演示插件验证
- 修复失效引用:hr_admin 死账号、404 测试基地 → 改为一次性沙箱基地模式
- 补充认证提示:管理类路由(如 DELETE /api/plugin)PAT 403 需 session 回退
- deployment.md 部署脚本改用 PAT 鉴权与必填 BASE_ID

评估:6 题 × 2 轮 24 次运行(含真实上传/清理链路);description 经
触发评估循环验证为原版最优,保持不变
2026-09-29 01:47:54 +00:00
yeuimu f2c2c9e399 feat(skill): inkreach-teable 按 skill-creator 评估流程迭代优化
- 路由表新增 select 字段写入规则(先查 choices/新值先加选项,typecast 静默失败)
- scheduledTime 期望管理:用户要具体时刻/星期须提前告知后端不支持
- 常见错误表补充 mutationApplied=false 静默失败条目
- 快速语法块补充 webhook 调用 URL 形状
- description 按触发评估循环结果重写(20 题评估集,保留测试集分数胜出)
- evals.json 更新为 11 题评估集(沙箱隔离、含 secrets/依赖/选项更新覆盖)

评估:Pi + opencode-go/deepseek-v4.1-flash,两轮迭代 28 次运行,
改进版 8 类任务近满分且耗时 -23%
2026-09-29 01:47:48 +00:00
yeuimu e09ed1bc0f feat: 收录 inkreach-teable-plugin skill;install.sh 安装全部技能
skills/ 现包含 inkreach-teable + inkreach-teable-plugin,
npx skills add 一并安装两个;install.sh 改为遍历安装 skills/*。
2026-09-23 06:23:46 +00:00
yeuimu d81d14bd52 docs(skill): 去除全部 localhost:3003 —— 一律使用公网地址 table.inkreach.cc
skill 面向公开分发,不应包含部署机内网地址。SKILL.md / cli-reference /
rest-api / instance-access 共 10 处替换;rest-api 双源表与 ssh 隧道
兜底整段删除(前端 rewrite 已让域名全功能可用)。
2026-09-23 05:54:15 +00:00
yeuimu 65437e59d4 docs: 引导词定稿——三步带说明,公网鉴权地址 2026-09-23 05:50:38 +00:00
yeuimu 2f8b89686f docs: 精简 README 引导词为纯命令 2026-09-23 05:46:28 +00:00
yeuimu be2750108e docs: README 引导词去掉 teable 兼容警告,路径统一为 ~/.agents/skills 2026-09-23 05:45:06 +00:00
yeuimu 8499de27e6 docs(skill): swagger is a curated registry — audit-verified gaps both ways
Cross-diff of all @Controller routes vs docs-json (486 vs 438 after shape
normalization): real undocumented routes include the entire authority-matrix
family (live 200, 0 doc entries), select-visibility, plugin runtime/proxy,
ShareDB socket internals, oauth-server, health; ~27 doc entries belong to
the enterprise backend and 404 on this CE instance. Completeness check =
controller grep, docs-json alone is not exhaustive.
2026-09-23 05:43:13 +00:00
yeuimu 20c1e48193 docs(skill): swagger now reachable on the public domain (frontend rewrite) 2026-09-23 05:39:01 +00:00
yeuimu c5e291a7cf fix(skill): public domain 404s swagger — origin-aware base URL + discovery
Verified live: https://table.inkreach.cc/docs-json and /docs → 404
(frontend doesn't proxy swagger paths), while /api/* IS proxied
(PAT auth → 200). Guide now: two-origin table (domain for API calls,
backend origin for swagger), ssh-tunnel fallback for remote machines,
backend-controller grep as last resort; instance-access.md connection
table gains the public-domain row.
2026-09-23 05:33:35 +00:00
yeuimu 3df9225a68 docs(skill): full-route honesty — ~362 backend paths, core documented, rest via /docs-json
Audit vs live swagger: guide covers the CLI-mirrored operational core;
added verified aggregation/row-count + a full inventory section pointing
to the unauthenticated /docs-json spec for the ~35 uncovered groups
(auth/session, user, spaces, share, attachments, import/export, trash,
templates, org/oauth/admin/billing, official webhooks, plugin UI…).
2026-09-23 05:29:11 +00:00
yeuimu 2530eea8b5 feat(skill): teach direct REST API usage — api-reference/rest-api.md
Comprehensive review found the skill taught CLI-only usage (just one
script-action callback snippet); nothing on calling the API directly.

New guide (verified against the live instance):
- base URL + Bearer PAT auth, Node v24 fetch header-drop warning
- full endpoint map mirrored from the CLI's HTTP layer (records/fields/
  tables/views/presets/workflow/secrets/executions/fire/sql/PAT/signup)
- canonical curl examples incl. typecast/fieldKeyType bodies, url-encoded
  filter, convert-for-options, workflow node PUT + activate, script-action
  callback via triggerInput._apiBaseUrl
- gotchas: typecast never creates choices (silent no-op), visibilityFilter
  400, PATCH field strips options, whole-graph node replace, search[] tuple

SKILL.md: module table row + routing rule wired to the new guide.
2026-09-23 05:20:14 +00:00
yeuimu 4a65294626 docs: README reduced to the agent bootstrap prompt only 2026-09-23 04:47:07 +00:00
yeuimu eefdceffe8 docs: npx-based install (verified) — CLI via git+https, skill via skills add
- CLI: npx git+https://... (ad-hoc) / npm i -g git+https://... (permanent)
- skill: npx -y skills add https://... -g -y --copy (installs to
  ~/.zcode/skills + ~/.claude/skills + ~/.agents/skills + ~40 agents)
- prefix trap documented: npm/npx NEED git+, skills add must NOT have it
- install.sh kept as offline/no-npm fallback
2026-09-23 04:39:15 +00:00
yeuimu b4d83f4f58 feat: npx/git-install support — prepare script + files whitelist
prepare builds dist on git-clone installs (npx/npm i -g from the gitea
URL); files whitelist overrides .gitignore so dist ships in the pack.
2026-09-23 04:33:17 +00:00
yeuimu 73e189fd97 docs: copy-paste agent bootstrap prompt (引导词) for fresh hosts/sessions 2026-09-23 04:08:49 +00:00
yeuimu 76ef02d659 refactor: move skill to skills/inkreach-teable (from .agents/skills/)
git mv rename (history preserved); install.sh + README updated.
The skill is consumed via user-level install (~/.zcode/skills), so
in-repo .agents discovery was unnecessary.
2026-09-23 04:07:39 +00:00
yeuimu b7eb065c20 fix(install): repo root is 4 levels up from scripts/, not 3
The path math bug predates the split (same wrong depth in the monorepo
version); it only surfaces now that the error branch is reachable.
2026-09-23 04:01:55 +00:00
44 changed files with 4070 additions and 251 deletions
@@ -1,33 +0,0 @@
{
"skill_name": "inkreach-teable",
"evals": [
{
"id": 1,
"name": "button-click-automation",
"prompt": "帮我在 order_logs 表里加一个按钮字段叫「催单」,点这个按钮的时候触发一个自动化,自动化里跑一段脚本,脚本内容是返回当前记录的订单号字段。然后激活它,点一下按钮测试看看执行结果。",
"expected_output": "应创建 button 字段(options.workflow.id 指向新 workflow)、创建 workflow、设 buttonClick 触发器、加 script action(读取 triggerInput.record.fields)、激活、fire button-click 测试、runs list 看到 succeeded。关键:button options 用 workflow 对象结构而非 workflowId 字符串。",
"files": []
},
{
"id": 2,
"name": "scheduled-digest",
"prompt": "我想每天定时跑一次自动化,去查一下 order_logs 表里所有记录,把数量统计出来。帮我配一个定时自动化。",
"expected_output": "应创建 workflow、设 scheduledTime 触发器(timing.type + interval,不配后端忽略的字段如 triggerTime/weekdays)、加 getRecords action、加 script action 统计、激活。关键:知道 scheduledTime 后端只认 type+interval。",
"files": []
},
{
"id": 3,
"name": "record-condition-trigger",
"prompt": "当 order_logs 表里某条记录的「结果」字段变成「失败」的时候,自动发一封邮件通知到 ops@xingchen.tech。帮我配这个自动化。",
"expected_output": "应创建 workflow、设 recordCondition 触发器(用 conditions 而非 filter,category 是 recordCondition 而非 recordMatchesConditions)、加 sendEmail action、激活。关键:用正确的自研契约键名 conditions。",
"files": []
},
{
"id": 4,
"name": "debug-failed-run",
"prompt": "我有个自动化 wfl5F8dQRDloo28w909,最近好像没正常跑,帮我看看是什么情况。",
"expected_output": "应先 workflow get 查 isActive、再 runs list --status failed 查失败记录、runs get 看具体错误。关键:用排查流程而非盲目重新激活。",
"files": []
}
]
}
+26 -36
View File
@@ -1,50 +1,40 @@
# inkreach-teable-cli # inkreach-teable-cli
操作 [inkreach-teable](ssh://git@gitea.inkreach.cc:2222/inkreachhub/inkreach-teable.git) inkreach-teable 实例的运维 CLI(npm 包 `@inkreach/inkreach-teable`)与
实例(Teable fork + 自建自动化引擎)的独立工具项目,包含两部分: **全部 Agent Skills 的唯一维护仓库**。
| 内容 | 位置 | 说明 | ## Skills(本仓库 `skills/` 目录 = single source of truth)
|---|---|---|
| **CLI**(命令 `inkreach-teable`) | 仓库根(`src/`,包即仓库) | 表、字段、记录、视图、自动化(8 触发器 + 7 动作)、密钥、执行历史、插件、订阅等全部 REST API 封装 |
| **Agent Skill** | `.agents/skills/inkreach-teable/` | ZCode/Claude 技能:模块地图、路由规则、API 参考、实例运维指南 |
设计文档见 [`docs/cli-skill-design.md`](docs/cli-skill-design.md)。 | Skill | 用途 |
|---|---|
| `inkreach-teable` | 操作已部署实例:表格/字段/记录/视图/自动化日常运维、REST API 参考 |
| `inkreach-teable-plugin` | 开发动态 React 组件插件:IIFE bundle(webpack/vite 双模板)、多资产上传(chunk + sourcemap)、`teable-plugin dev` 热更新闭环、5 个挂载位(含整页 route) |
> ⚠️ 官方 `teable` CLI 与本实例的自动化契约不兼容(`conditions` 而非 `filter`、 **维护规则**:所有 skill 只在本仓库修改;主仓库(inkreach-teable)不含任何
> `fieldIds` 而非 `watchFieldIds` 等),务必使用 `inkreach-teable`。 skill 源文件。改完后同步到安装位置(`~/.zcode/skills/` 为主,
`~/.agents/skills/` 为镜像):
## 安装
```bash ```bash
git clone ssh://git@gitea.inkreach.cc:2222/inkreachhub/inkreach-teable-cli.git cp -r skills/<name>/SKILL.md skills/<name>/metadata.json skills/<name>/dynamic-plugins ~/.zcode/skills/<name>/
cd inkreach-teable-cli cp -r skills/<name>/ ~/.agents/skills/<name>/ # 按需镜像
bash .agents/skills/inkreach-teable/scripts/install.sh
``` ```
安装脚本会:构建 CLI → 全局链接 `inkreach-teable` 命令 → 把 skill 安装到 ## 安装(任意机器 / 任意会话的 Agent)
`~/.zcode/skills/inkreach-teable`(用户级,任意工作区可用)。更新后重跑一次即可。
## 配置 把下面这段引导词发给 Agent 即可完成安装并开始使用:
```bash ```text
inkreach-teable auth login --endpoint http://localhost:3003 --token <PAT> --base-id bseXXX 请安装并用 inkreach-teable CLI 操作 inkreach-teable:
inkreach-teable auth status 1. 安装 cli:
npm i -g @inkreach/inkreach-teable
2. 安装技能:
npx -y skills add https://gitea.inkreach.cc/inkreachhub/inkreach-teable-cli.git -g -y --copy
3. 鉴权(PAT 从 Web UI Settings → Personal Access Tokens 获取):
inkreach-teable auth login --endpoint https://table.inkreach.cc --token <PAT> --base-id bseXXX
``` ```
PAT 从 Web UI(Settings → Personal Access Tokens)或 `auth token create` 获取。 ## 目录结构
## 开发 - `skills/` — Agent Skills(本仓库的核心资产之一)
- `src/` — CLI 源码(commander 实现)
```bash - `docs/cli-skill-design.md` — CLI + Skill 的设计文档
pnpm install
pnpm build # tsc → dist/
pnpm test # vitest 单测(field-normalize / field-display / resources)
pnpm typecheck
```
## 来源
2026-09-23 从 inkreach-teable monorepo 拆分独立(原路径 `packages/inkreach-cli/` +
`.agents/skills/inkreach-teable/`),git 历史完整保留。应用本体的开发/部署文档见
skill 内 [development.md](.agents/skills/inkreach-teable/guides/development.md)(描述的是
inkreach-teable 应用仓库,不是本仓库)。
+2 -2
View File
@@ -106,7 +106,7 @@ inkreach-teable [全局选项] <命令>
fire webhook <baseId> <workflowId> [--body <json>] 模拟 webhook fire webhook <baseId> <workflowId> [--body <json>] 模拟 webhook
SQL(只读): SQL(只读):
sql <sql> POST /api/base/{baseId}/sql ~~sql <sql>~~ (removed in 0.1.5 — backend has no SQL query API)
``` ```
### 3.3 关键设计决策 ### 3.3 关键设计决策
@@ -170,7 +170,7 @@ YAML frontmatter(name + 极详细的 description 触发条件)+ 以下章节
2. **基础资源命令**:base/table/field/record/view(官方兼容端点,最简单)。 2. **基础资源命令**:base/table/field/record/view(官方兼容端点,最简单)。
3. **workflow 命令**:list/get/create/update/delete/activate/deactivate/test。 3. **workflow 命令**:list/get/create/update/delete/activate/deactivate/test。
4. **触发器/Action 便捷封装**:`trigger set` / `action add/update/delete`(内部 GET-modify-PUT 全图)。 4. **触发器/Action 便捷封装**:`trigger set` / `action add/update/delete`(内部 GET-modify-PUT 全图)。
5. **secrets / runs / fire / sql**。 5. **secrets / runs / fire**(sql 已在 0.1.5 移除:后端无 SQL 查询 API)。
6. **skill 文档**:SKILL.md + guides + api-reference + metadata + install.sh。 6. **skill 文档**:SKILL.md + guides + api-reference + metadata + install.sh。
7. **端到端验证**:真实实例跑通全链路。 7. **端到端验证**:真实实例跑通全链路。
8. **注册**:在 AGENTS.md 添加 skill 说明。 8. **注册**:在 AGENTS.md 添加 skill 说明。
+25
View File
@@ -0,0 +1,25 @@
# Skill/CLI eval harness
14 题 teable 操作 eval + 6 题 plugin eval + 逐次尝试统计。单源真值在本目录,
运行产物默认落 `/root/eval-rerun`(可用 `EVAL_ROOT` 覆盖)。
## 目录
- `teable-evals.json` / `plugin-evals.json` — 题目(prompt 模板,支持 {TAG}/{WFID}/{TBL5}/{ORD}/{TBL})
- `run_evals4.py` — 启动器。支持配置: `with_skill=<skill>:<bin>`, `old_skill=...`, `auto_skill=NONE:<bin>`(触发模式:不给技能路径,靠用户级技能自动发现);`EVAL_MODEL` 换模型
- `grade_evals4.py` / `grade_plugin_evals.py` — 判分(按最终状态优先)。重判历史轮次须冻结该轮 env(`EVAL_ENV`)
- `run_multiturn.py` + `grade_multiturn.py` — 两轮修正式对话 eval(pi `--session-id` 续接;判分认 recordCondition 或 recordUpdated+门控两种正确形状)
- `analyze_attempts.py ROOT...` — 尝试次数分布(第几次成功/never;`EVAL_CFG` 选配置目录)
- `analyze_trajectories.py` — 轨迹解析公用库(失败签名分类器)
## 一次完整 A/B
```bash
EVAL_ROOT=/root/eval-rerun python3 evals/run_evals4.py iteration-X A1 \
"with_skill=/path/skill-new:/path/bin,old_skill=/path/skill-old:/path/bin-old"
python3 evals/grade_evals4.py iteration-X A1
python3 evals/analyze_attempts.py /root/eval-rerun/iteration-X
```
## 环境要求
- pi(opencode-go 凭据)+ 隔离 HOME(无用户级技能,除 auto_skill 模式用带技能副本)
- 每轮独立沙箱 base;判分后清理。**一切建库仅在 agent-playground 专用空间**。
- eval-12 需低权限 PAT(sandbox-env.json 的 LOWPERM_TOKEN)
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Attempts-to-success distribution for iteration-5 (N=3 variance rerun).
For every CLI verb session across all runs: which attempt number succeeded
(1 / 2 / 3+ / never)? The user-facing goal is "1-2 attempts per call".
"""
import json, glob, os, sys
from pathlib import Path
from collections import defaultdict
sys.path.insert(0, str(Path(__file__).parent))
from analyze_trajectories import load_run, classify_output, cli_verb
def sessions_for(pi):
calls, meta = load_run(pi)
cli = []
for tool, cmd, out in calls:
if tool != 'bash':
continue
cli.append((cli_verb(cmd), cmd, classify_output(out, cmd)))
# verb -> attempts used until success (per contiguous session)
stats = defaultdict(lambda: {'sess': 0, 'a1': 0, 'a2': 0, 'a3p': 0, 'never': 0})
cur = defaultdict(lambda: {'fails': 0, 'done': False})
for verb, cmd, sig in cli:
if not verb:
continue
st = cur[verb]
if sig is None:
if not st['done']:
stats[verb]['sess'] += 1
n = st['fails'] + 1
if n == 1: stats[verb]['a1'] += 1
elif n == 2: stats[verb]['a2'] += 1
else: stats[verb]['a3p'] += 1
st['done'] = True; st['fails'] = 0
else:
if not st['done']:
st['fails'] += 1
for verb, st in cur.items():
if not st['done'] and st['fails']:
stats[verb]['sess'] += 1; stats[verb]['never'] += 1
return stats
def main():
roots = sys.argv[1:] or ['/root/eval-rerun/iteration-5']
cfg_glob = os.environ.get('EVAL_CFG', 'with_skill')
tot = {'sess': 0, 'a1': 0, 'a2': 0, 'a3p': 0, 'never': 0}
zero = dict(tot)
per_verb = defaultdict(lambda: dict(zero))
npass = nrun = 0
files = sorted(f for r in roots for f in glob.glob(f'{r}/eval-*/{cfg_glob}/run-1/pi.jsonl'))
for pi in files:
nrun += 1
gp = os.path.join(os.path.dirname(pi), 'grading.json')
if os.path.exists(gp):
try:
g = json.load(open(gp)); exps = g.get('expectations') or []
if exps and all(x.get('passed') for x in exps):
npass += 1
except Exception:
pass
for verb, st in sessions_for(pi).items():
for k in tot:
per_verb[verb][k] += st[k]
tot[k] += st[k]
print(f"runs={nrun} eval-pass={npass}/{nrun}")
s = tot['sess']
if s:
print(f"\nverb sessions: {s}")
print(f" attempt 1: {tot['a1']:4} ({tot['a1']/s*100:.1f}%)")
print(f" attempt 2: {tot['a2']:4} ({tot['a2']/s*100:.1f}%)")
print(f" attempt 3+: {tot['a3p']:4} ({tot['a3p']/s*100:.1f}%)")
print(f" never ok: {tot['never']:4} ({tot['never']/s*100:.1f}%)")
print(f" ≤2 attempts: {(tot['a1']+tot['a2'])/s*100:.1f}%")
print(f"\nper-verb (only verbs with a retry or never):")
for verb, st in sorted(per_verb.items(), key=lambda kv: -(kv[1]['a2']+kv[1]['a3p']+kv[1]['never'])):
if st['a2'] or st['a3p'] or st['never']:
print(f" {verb:24} sess={st['sess']:3} a1={st['a1']:3} a2={st['a2']} a3+={st['a3p']} never={st['never']}")
print(f"\nall verbs:")
for verb, st in sorted(per_verb.items(), key=lambda kv: -kv[1]['sess']):
print(f" {verb:24} sess={st['sess']:3} a1={st['a1']:3} a2={st['a2']} a3+={st['a3p']} never={st['never']}")
if __name__ == '__main__':
main()
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""Trajectory analyzer for pi-skill-lab eval transcripts.
Parses pi.jsonl transcripts, reconstructs per-run tool-call sequences,
detects failed / retried commands (output-text heuristics — pi's bash tool
returns no exit codes), and correlates with grading.json outcomes.
Outputs:
1. per-run summary (effort + failure counts + doc-read order)
2. aggregated failure signatures per config
3. first-attempt success rate for the core CLI verbs
"""
import json, glob, os, re, sys
from collections import defaultdict
# --- failure heuristics on tool OUTPUT text (stdout only in pi) -------------
# Strict failure patterns: an output line is a FAILURE only when it looks like
# an actual error line — NOT when the word "failed"/"error" appears in normal
# status output (e.g. `runs list` printing status=failed, or "error": null).
FAIL_LINES = [
r'^\s*[✗✘❌]', r'^\s*error\b', r'\bError:', r'Validation error',
r'command not found', r'ENOENT', r'ECONNREFUSED', r'ETIMEDOUT',
r'unauthorized', r'\bforbidden\b', r'Missing required',
r'unknown (option|command)', r'is required',
r'HTTP_?[45]\d\d', r'Cannot (GET|POST|PUT|PATCH|DELETE)\b',
r'"status":\s*[45]\d\d', r'Invalid (option|value|input|RecordId|List)',
r'Unsupported library type', r'spawn npm ENOENT',
]
FAIL_RE = re.compile('|'.join(FAIL_LINES))
# outputs that legitimately contain these words but are NOT failures
OK_RE = re.compile(r'^(id|name|baseId|workflowId|triggerType|config|next|note|\s*$)', re.M)
def classify_output(text, cmd=''):
"""Return failure signature (short str) or None if output looks successful."""
if not text:
return None
# `--help` legitimately prints "Usage:" — not a failure
is_help = '--help' in cmd
head = text[:400]
if is_help and re.match(r'\s*(Usage:|inkreach-teable \[options\])', text):
return None
for ln in text.splitlines():
ln = ln.strip()
if ln and FAIL_RE.search(ln):
if is_help and ln.startswith('Usage:'):
continue
return ln[:140]
return None
def cmd_of(args):
if not isinstance(args, dict):
return ''
return args.get('command') or args.get('path') or ''
def load_run(pi_path):
"""Return ordered tool calls [(tool, cmd, out_head)], plus meta."""
calls, meta = [], {'turns': 0, 'settled': False}
starts = {}
for line in open(pi_path):
try:
e = json.loads(line)
except Exception:
continue
t = e.get('type')
if t == 'tool_execution_start':
starts[e['toolCallId']] = e
elif t == 'tool_execution_end':
s = starts.get(e['toolCallId'], {})
args = s.get('args', {})
r = e.get('result') or {}
c = r.get('content')
txt = ''
if isinstance(c, list):
txt = '\n'.join(x.get('text', '') for x in c if isinstance(x, dict))
elif isinstance(c, str):
txt = c
calls.append((e['toolName'], cmd_of(args), txt[:600]))
elif t == 'turn_start':
meta['turns'] += 1
elif t == 'agent_settled':
meta['settled'] = True
return calls, meta
def cli_verb(cmd):
m = re.search(r'(?:^|\s|&&|;|npx\s+(?:git+\S+\s+)?)inkreach-teable\s+([a-z-]+)(?:\s+([a-z-]+))?', cmd)
if not m:
return None
return '.'.join(x for x in m.groups() if x)
def analyze(pi_path):
calls, meta = load_run(pi_path)
run = {
'path': pi_path,
'total': len(calls),
'bash': sum(1 for t, c, o in calls if t == 'bash'),
'reads': sum(1 for t, c, o in calls if t == 'read'),
'turns': meta['turns'],
'settled': meta['settled'],
'cli': [], # (verb, cmd, fail_sig_or_None)
'other_fail': [], # non-CLI failed bash (npm, curl, node...)
'docs': [], # skill files read, in order
}
seen_verbs = {}
for tool, cmd, out in calls:
if tool == 'read':
if '/skills/' in cmd and cmd.endswith('.md'):
run['docs'].append(cmd.split('/skills/')[-1])
continue
if tool != 'bash':
continue
sig = classify_output(out, cmd)
v = cli_verb(cmd)
if v:
run['cli'].append((v, cmd, sig))
elif sig:
run['other_fail'].append((cmd[:100], sig))
return run
def attempts_to_success(cli_calls):
"""Per verb: list of (#failed attempts before first success, succeeded?)."""
stats = defaultdict(lambda: {'fails_before': [], 'never': 0, 'first_try': 0, 'total_sessions': 0})
cur = defaultdict(lambda: {'fails': 0, 'succeeded': False})
for verb, cmd, sig in cli_calls:
st = cur[verb]
if sig is None:
if not st['succeeded']:
stats[verb]['total_sessions'] += 1
stats[verb]['fails_before'].append(st['fails'])
if st['fails'] == 0:
stats[verb]['first_try'] += 1
st['succeeded'] = True
st['fails'] = 0
else:
if not st['succeeded']:
st['fails'] += 1
# verbs that never succeeded
for verb, st in cur.items():
if not st['succeeded'] and st['fails']:
stats[verb]['total_sessions'] += 1
stats[verb]['never'] += 1
return stats
def main():
rows = []
for ws in ['inkreach-teable-workspace', 'plugin-workspace']:
for pi in sorted(glob.glob(f'/root/pi-skill-lab/{ws}/iteration-*/eval-*/*/run-*/pi.jsonl')):
parts = pi.split('/')
run = analyze(pi)
gp = os.path.join(os.path.dirname(pi), 'grading.json')
passed = None
if os.path.exists(gp):
try:
g = json.load(open(gp))
exps = g.get('expectations') or []
if exps:
passed = all(x.get('passed') for x in exps)
else:
passed = g.get('passed')
except Exception:
pass
rows.append({
'ws': ws.replace('-workspace',''), 'iter': parts[4],
'eval': parts[5], 'cfg': parts[6], 'run': parts[7].replace('run-',''),
'passed': passed, **{k: run[k] for k in ('total','bash','reads','turns','settled','cli','other_fail','docs')},
})
# ---- 1. per-run summary ----
print('=' * 110)
print('PER-RUN TRAJECTORY SUMMARY')
print('=' * 110)
hdr = f"{'eval':38} {'cfg':12} {'ok':4} {'tcs':4} {'rd':3} {'trn':4} {'cli':4} {'f':3} {'retry':5} docs"
print(hdr)
for r in rows:
fails = sum(1 for v,c,s in r['cli'] if s)
retries = sum(1 for v,c,s in r['cli'] if s) # failed call == at least one retry if task later passed
cli_ok = 'Y' if r['cli'] else '-'
print(f"{r['eval'][:38]:38} {r['cfg']:12} {str(r['passed'])[:3]:4} {r['total']:4} {r['reads']:3} {r['turns']:4} "
f"{len(r['cli']):4} {fails:3} {retries:5} {len(r['docs'])}d {'>' if not r['settled'] else ''}")
# ---- 2. failure signatures ----
print()
print('=' * 110)
print('FAILURE SIGNATURES (failed CLI / other-bash calls, per config)')
print('=' * 110)
sigs = defaultdict(list)
for r in rows:
for verb, cmd, sig in r['cli']:
if sig:
sigs[(r['cfg'], verb, sig[:70])].append(r['eval'][:30])
for cmd, sig in r['other_fail']:
sigs[(r['cfg'], 'bash:'+cmd[:40], sig[:70])].append(r['eval'][:30])
for (cfg, verb, sig), evals in sorted(sigs.items()):
print(f"[{cfg:12}] {verb:32} n={len(evals):2} {sig}")
# ---- 3. first-attempt success per verb, per config ----
print()
print('=' * 110)
print('ATTEMPTS-TO-SUCCESS PER CLI VERB (per config)')
print('=' * 110)
agg = defaultdict(lambda: defaultdict(lambda: {'sess':0,'first':0,'never':0,'retry_hist':[]}))
for r in rows:
st = attempts_to_success(r['cli'])
for verb, s in st.items():
a = agg[r['cfg']][verb]
a['sess'] += s['total_sessions']; a['first'] += s['first_try']; a['never'] += s['never']
a['retry_hist'] += [x for x in s['fails_before'] if x]
for cfg in agg:
print(f'--- {cfg} ---')
for verb, a in sorted(agg[cfg].items(), key=lambda kv: -kv[1]['sess']):
ft = f"{a['first']}/{a['sess']}" if a['sess'] else '-'
print(f" {verb:34} sessions={a['sess']:3} first-try={ft:7} never-ok={a['never']:2} retry-dist={sorted(set(a['retry_hist'])) if a['retry_hist'] else []}")
# ---- 4. failed runs: what happened ----
print()
print('=' * 110)
print('FAILED/TIMEOUT RUNS — trajectory tail')
print('=' * 110)
for r in rows:
if r['passed'] is False or not r['settled']:
fails = [(v, s) for v, c, s in r['cli'] if s] + [(c[:50], s) for c, s in r['other_fail']]
print(f"\n{r['ws']} {r['iter']}/{r['eval']}/{r['cfg']} passed={r['passed']} settled={r['settled']}")
for v, s in fails[-6:]:
print(f" FAIL {v}: {s}")
if __name__ == '__main__':
main()
+376
View File
@@ -0,0 +1,376 @@
#!/usr/bin/env python3
"""Grade iteration-4 A/B rerun evals.
Same checks as the original grade_evals.py, adapted to per-config sandboxes:
- each config (with_skill/old_skill) has its own base + order_logs table + ORD record
- CLI = absolute path to the NEW CLI (reads only; version-agnostic)
Usage: python3 grade_evals4.py <iteration-name> <tag>
"""
import json
import os
import re
import subprocess
import sys
from pathlib import Path
HARNESS = Path(__file__).parent # evals/ itself
ROOT = Path(os.environ.get("EVAL_ROOT", "/root/eval-rerun"))
CLI = os.environ.get("EVAL_CLI", "/root/eval-rerun/bin-new/bin/inkreach-teable")
env_all = json.loads(Path(os.environ.get('EVAL_ENV', str(HARNESS / 'sandbox-env.json'))).read_text())
def cli_json(cfg, *args):
e = {**os.environ,
"INKREACH_TEABLE_ENDPOINT": env_all["INKREACH_TEABLE_ENDPOINT"],
"INKREACH_TEABLE_TOKEN": env_all["INKREACH_TEABLE_TOKEN"],
"INKREACH_TEABLE_BASE_ID": cfg["INKREACH_TEABLE_BASE_ID"]}
p = subprocess.run([CLI, *args, "--json"], capture_output=True, text=True, env=e, timeout=60)
try:
return json.loads(p.stdout)
except json.JSONDecodeError:
return None
def transcript(run_dir: Path):
cmds, reads = [], []
for line in (run_dir / "pi.jsonl").read_text(errors="replace").splitlines():
line = line.strip()
if not line:
continue
try:
ev = json.loads(line)
except json.JSONDecodeError:
continue
if ev.get("type") != "tool_execution_start":
continue
if ev.get("toolName") == "bash":
cmds.append(ev.get("args", {}).get("command", ""))
elif ev.get("toolName") == "read":
reads.append(ev.get("args", {}).get("path", ""))
return cmds, reads
def answer(run_dir: Path) -> str:
fa = run_dir / "final_answer.md"
af = run_dir / "assistant_final.md"
return (fa.read_text(errors="replace") if fa.exists() else af.read_text(errors="replace"))
def used_official_teable(cmds):
for c in cmds:
# probing for existence (command -v / which) is prudent, not usage
probe = re.search(r"(command -v|which)\s+teable", c)
if probe:
continue
if re.search(r"(^|[^a-z-])teable\s", c) or "npx teable" in c or re.search(r"npm\s+(i|install)\s+teable\b", c):
return c
return None
def find_wf(cfg, name_part):
wfs = cli_json(cfg, "workflow", "list") or []
if not isinstance(wfs, list):
return None # error payload ({"error":...}) — not a workflow list
for w in wfs:
if isinstance(w, dict) and name_part in w.get("name", ""):
return w
return None
def wf_nodes(cfg, wf_id):
wf = cli_json(cfg, "workflow", "get", wf_id) or {}
return wf, [n for n in wf.get("nodes", []) if n.get("type") == "trigger"], [n for n in wf.get("nodes", []) if n.get("type") == "action"]
def result(text, passed, evidence):
return {"text": text, "passed": bool(passed), "evidence": str(evidence)[:400]}
def grade_eval(cfg, ev_name, tag, run_dir, cfg_short="N"):
tbl = cfg["TBL5_ID"]
ord_id = cfg["ORD"]
cmds, reads = transcript(run_dir)
ans = answer(run_dir)
all_cmds = "\n".join(cmds)
out = []
if ev_name == "button-click-automation":
# Locate the workflow via the button-field binding (options.workflow) —
# the prompt only fixes the FIELD name, agents may name the workflow
# anything (e.g. 催单自动化A4W), so searching by workflow name misfires.
fields = cli_json(cfg, "field", "list", tbl) or []
btn = next((f for f in fields if f.get("name") == f"催单按钮{tag}"), None)
wf = None
if btn:
wf_id = ((btn.get("options") or {}).get("workflow") or {}).get("id")
if wf_id:
wf = cli_json(cfg, "workflow", "get", wf_id)
if not wf:
wf = find_wf(cfg, f"催单按钮{tag}")
if not wf:
out.append(result("按钮字段绑定的工作流存在 (催单按钮%s)" % tag, False,
"field list 无该按钮字段或其 options.workflow 无效"))
return out
wf_full, triggers, actions = wf_nodes(cfg, wf["id"])
out.append(result("工作流已激活 (isActive)", wf_full.get("isActive") is True, "isActive=%s" % wf_full.get("isActive")))
fields = cli_json(cfg, "field", "list", tbl) or []
btn = next((f for f in fields if f.get("name") == f"催单按钮{tag}"), None)
if not btn:
out.append(result("创建按钮字段 催单按钮%s" % tag, False, "field list 未找到该字段"))
else:
wf_opt = (btn.get("options") or {}).get("workflow")
ok = isinstance(wf_opt, dict) and wf_opt.get("id") == wf["id"]
out.append(result("按钮字段 options.workflow 为对象且指向该工作流", ok,
"options.workflow=%r" % wf_opt))
out.append(result("触发器为 buttonClick", any(t.get("category") == "buttonClick" for t in triggers),
"trigger categories=%s" % [t.get("category") for t in triggers]))
script = next((a for a in actions if a.get("category") == "script"), None)
out.append(result("script action 读取 triggerInput", script and "triggerInput" in (script.get("config") or {}).get("code", ""),
"code=%r" % ((script or {}).get("config") or {}).get("code", "")[:120]))
fired = any("fire button-click" in c for c in cmds)
runs_any = cli_json(cfg, "runs", "list", wf["id"]) or []
verified = any(re.search(r"runs (list|get)", c) for c in cmds) or len(runs_any) > 0
out.append(result("用 fire button-click 实测", fired, [c for c in cmds if "fire" in c][:2]))
out.append(result("执行后查看 runs 结果", verified, [c for c in cmds if "runs" in c][:2]))
runs = cli_json(cfg, "runs", "list", wf["id"]) or []
ok_run = any(r.get("status") in ("succeeded", "success") for r in runs)
out.append(result("存在 succeeded 的执行记录", ok_run, "statuses=%s" % [r.get("status") for r in runs][:5]))
bad = used_official_teable(cmds)
out.append(result("全程使用 inkreach-teable CLI(未用官方 teable)", bad is None, bad or "ok"))
elif ev_name == "scheduled-digest":
wf = find_wf(cfg, f"每日统计{tag}")
if not wf:
out.append(result("创建名为 每日统计%s 的工作流" % tag, False, "workflow list 中未找到"))
return out
wf_full, triggers, actions = wf_nodes(cfg, wf["id"])
out.append(result("工作流已激活", wf_full.get("isActive") is True, "isActive=%s" % wf_full.get("isActive")))
trig = triggers[0] if triggers else {}
timing = (trig.get("config") or {}).get("timing") or {}
ignored = [k for k in ("triggerTime", "weekdays", "daysOfMonth", "starting", "ending", "timezone", "tz") if k in timing]
out.append(result("scheduledTime 触发器", trig.get("category") == "scheduledTime", "category=%s" % trig.get("category")))
out.append(result("timing 只含生效字段(type+interval,无被忽略字段)", trig.get("category") != "scheduledTime" or (not ignored and "type" in timing and "interval" in timing),
"timing=%r ignored=%s" % (timing, ignored)))
has_stats = any(a.get("category") == "script" and re.search(r"record|count|统计|length", (a.get("config") or {}).get("code", "")) for a in actions) or any(
a.get("category") in ("getRecords",) for a in actions)
out.append(result("有统计逻辑的 action(script 读记录数或 getRecords)", has_stats,
"action categories=%s" % [a.get("category") for a in actions]))
elif ev_name == "record-condition-email":
wf = find_wf(cfg, f"失败告警{tag}")
if not wf:
out.append(result("创建名为 失败告警%s 的工作流" % tag, False, "workflow list 中未找到"))
return out
wf_full, triggers, actions = wf_nodes(cfg, wf["id"])
out.append(result("工作流已激活", wf_full.get("isActive") is True, "isActive=%s" % wf_full.get("isActive")))
trig = triggers[0] if triggers else {}
out.append(result("触发器 category 为 recordCondition", trig.get("category") == "recordCondition",
"category=%s" % trig.get("category")))
c = trig.get("config") or {}
cond_ok = "conditions" in c and "filter" not in c
out.append(result("条件键名为 conditions(无 filter)", cond_ok, "config keys=%s" % list(c.keys())))
if isinstance(c.get("conditions"), dict):
out.append(result("conditions 形如 {conjunction, filterSet}", "filterSet" in c["conditions"],
"conditions keys=%s" % list(c["conditions"].keys())))
bad_key = "recordMatchesConditions" in all_cmds
out.append(result("未使用官方键名 recordMatchesConditions", not bad_key, "transcript" if bad_key else "ok"))
email = next((a for a in actions if a.get("category") in ("sendEmail", "email")), None)
ok_email = email and "ops@xingchen.tech" in json.dumps(email.get("config") or {}, ensure_ascii=False)
out.append(result("sendEmail action 收件人 ops@xingchen.tech", ok_email,
"email action config=%s" % json.dumps((email or {}).get("config") or {}, ensure_ascii=False)[:200]))
elif ev_name == "debug-failed-run":
out.append(result("查询了工作流状态 (workflow get/list)", any(re.search(r"workflow (get|list)", c) for c in cmds),
[c for c in cmds if "workflow" in c][:2]))
out.append(result("查询失败执行 (runs list --status failed 或过滤)", any(re.search(r"runs list", c) for c in cmds),
[c for c in cmds if "runs" in c][:3]))
detail_in_ans = bool(re.search(r"boom|nodeResults|错误详情|执行失败原因|throw new Error", ans))
out.append(result("查看了具体执行详情 (runs get 或答复引用运行错误详情)",
any(re.search(r"runs get", c) for c in cmds) or detail_in_ans,
[c for c in cmds if "runs get" in c][:2] or ans[:120]))
diagnosed = re.search(r"boom|timeout|超时|抛错|throw", ans, re.I)
out.append(result("最终答复指出脚本抛错原因", bool(diagnosed), ans[:200]))
reactivated = [c for c in cmds if re.search(r"workflow activate", c)]
out.append(result("未盲目重新激活(诊断前不改)", not reactivated, reactivated or "ok"))
elif ev_name == "select-constraint-write":
inspected = any(re.search(r"field (list|get|visible-choices)", c) for c in cmds)
out.append(result("写入前检查了字段约束 (field list --with-options / field get)", inspected,
[c for c in cmds if "field" in c][:2]))
fields = cli_json(cfg, "field", "list", tbl) or []
f = next((x for x in fields if x.get("name") == "结果"), {})
choices = [c.get("name") if isinstance(c, dict) else c for c in ((f.get("options") or {}).get("choices") or [])]
added = "已完成" in choices
recs = (cli_json(cfg, "record", "get", "--table-id", tbl) or {}).get("records") or []
rec = next((r for r in recs if (r.get("fields") or {}).get("订单号") == ord_id), {})
val = (rec.get("fields") or {}).get("结果")
claims_success = bool(re.search(r"(✅|已.{0,3}(改|更新|设置|写入).{0,20}已完成|成功.{0,10}改为)", ans))
if added and val == "已完成":
out.append(result("合法路径:先加选项再写入,记录值=已完成", True, "choices=%s val=%s" % (choices, val)))
elif claims_success and val != "已完成" and not added:
out.append(result("未发生静默失败(声称成功但值未变)", False,
"答复声称已改为「已完成」,但实际 choices=%s 记录值=%r —— 教科书式 mutationApplied=false 静默失败" % (choices, val)))
elif val in (None, "处理中") and re.search(r"不在|无法|无效|不是.{0,6}选项|未写入|没有写入|未添加|无法直接|不能直接", ans):
out.append(result("识别出「已完成」不是有效选项并向用户说明(未静默失败)", True, ans[:250]))
else:
out.append(result("写入了有效选项值或正确说明", val == "已完成",
"choices=%s val=%r claims_success=%s ans=%s" % (choices, val, claims_success, ans[:150])))
listed = all(x in ans for x in ("成功", "失败", "处理中"))
out.append(result("答复列出了全部可选值(成功/失败/处理中)", listed, ans[:200]))
elif ev_name == "official-cli-near-miss":
bad = used_official_teable(cmds)
out.append(result("未安装/调用官方 teable CLI 打实例", bad is None, bad or "ok"))
out.append(result("使用了 inkreach-teable CLI", any("inkreach-teable" in c for c in cmds),
[c for c in cmds if "inkreach-teable" in c][:2]))
out.append(result("答复列出了 order_logs 表", "order_logs" in ans, ans[:200]))
elif ev_name == "webhook-receiver":
wf = find_wf(cfg, f"回单通知{tag}")
if not wf:
out.append(result("创建名为 回单通知%s 的工作流" % tag, False, "workflow list 中未找到"))
return out
wf_full, triggers, actions = wf_nodes(cfg, wf["id"])
out.append(result("工作流已激活", wf_full.get("isActive") is True, "isActive=%s" % wf_full.get("isActive")))
trig = triggers[0] if triggers else {}
out.append(result("触发器为 webhook", trig.get("category") == "webhook", "category=%s" % trig.get("category")))
base = cfg["INKREACH_TEABLE_BASE_ID"]
correct = f"/api/webhook/{base}/{wf['id']}"
out.append(result("答复给出正确 URL 形状 /api/webhook/{baseId}/{workflowId}(无 /base/ 段)", correct in ans,
"期望含 %s;答复=%s" % (correct, ans[:180])))
tested = any(re.search(r"fire webhook", c) for c in cmds) or any("api/webhook" in c and re.search(r"curl|POST", c) for c in cmds)
out.append(result("实际测试了 webhook(fire webhook 或 curl POST)", tested,
[c for c in cmds if "webhook" in c][:3]))
elif ev_name == "weekday-schedule-trap":
wf = find_wf(cfg, f"工作日盘点{tag}")
if not wf:
out.append(result("创建名为 工作日盘点%s 的工作流" % tag, False, "workflow list 中未找到"))
return out
wf_full, triggers, actions = wf_nodes(cfg, wf["id"])
out.append(result("工作流已激活", wf_full.get("isActive") is True, "isActive=%s" % wf_full.get("isActive")))
trig = triggers[0] if triggers else {}
timing = (trig.get("config") or {}).get("timing") or {}
ignored = [k for k in ("triggerTime", "weekdays", "daysOfMonth", "starting", "ending", "timezone", "tz") if k in timing]
out.append(result("scheduledTime 触发器且 timing 只含生效字段", trig.get("category") == "scheduledTime" and not ignored and "type" in timing,
"category=%s timing=%r ignored=%s" % (trig.get("category"), timing, ignored)))
warns = re.search(r"(忽略|不支持|仅|只).{0,30}(interval|间隔|type)|weekday|工作日.{0,10}(不|无法|忽略)|backend ignores", ans, re.I)
out.append(result("答复如实告知『具体时刻/星期』不被后端支持", bool(warns), ans[:250]))
claims_exact = re.search(r"(已.{0,6}(配好|设置).{0,40}(工作日|下午\s*5|17:00))|每个工作日下午 ?5 ?点.{0,10}(会|将)跑", ans)
out.append(result("未虚假承诺『工作日下午5点会照常执行』", not claims_exact, ans[:200]))
elif ev_name == "secret-env-usage":
wf = find_wf(cfg, f"汇率查询{tag}")
if not wf:
out.append(result("创建名为 汇率查询%s 的工作流" % tag, False, "workflow list 中未找到"))
return out
wf_full, triggers, actions = wf_nodes(cfg, wf["id"])
out.append(result("工作流已激活", wf_full.get("isActive") is True, "isActive=%s" % wf_full.get("isActive")))
secrets = cli_json(cfg, "secret", "list", wf["id"]) or []
has_secret = bool(secrets)
out.append(result("存了密钥(非明文入代码;secret set 或 secret list 可见)",
any(re.search(r"secret set", c) for c in cmds) or has_secret,
[c for c in cmds if "secret set" in c][:1] or f"secret list: {len(secrets)} 条"))
script = next((a for a in actions if a.get("category") == "script"), None)
code = (script or {}).get("config", {}).get("code", "")
out.append(result("脚本经 process.env 读取密钥", bool(re.search(r"process\.env\.[A-Z_]+", code)), code[:150]))
out.append(result("脚本/工作流配置中无明文 key(sk-test-abc123)", "sk-test-abc123" not in json.dumps(wf_full, ensure_ascii=False),
"明文检查通过" if "sk-test-abc123" not in json.dumps(wf_full, ensure_ascii=False) else "配置含明文!"))
elif ev_name == "script-npm-deps":
wf = find_wf(cfg, f"日期演示{tag}")
if not wf:
out.append(result("创建名为 日期演示%s 的工作流" % tag, False, "workflow list 中未找到"))
return out
wf_full, triggers, actions = wf_nodes(cfg, wf["id"])
out.append(result("工作流已激活", wf_full.get("isActive") is True, "isActive=%s" % wf_full.get("isActive")))
script = next((a for a in actions if a.get("category") == "script"), None)
code = (script or {}).get("config", {}).get("code", "") if script else ""
out.append(result("脚本使用 dayjs", "dayjs" in code, code[:150]))
out.append(result("依赖经 --dependencies 声明(配置含 dayjs)", "dayjs" in json.dumps((script or {}).get("config", {})),
json.dumps((script or {}).get("config", {}), ensure_ascii=False)[:200]))
out.append(result("知晓后端 npm PATH 前提(答复或命令体现)", any(re.search(r"--dependencies", c) for c in cmds) or bool(re.search(r"PATH|npm|后端|service", ans)),
ans[:180]))
elif ev_name == "lowperm-403-handling":
ans_l = ans.lower()
perm_ack = bool(re.search(r"权限|403|无权|forbidden|没.{0,4}(资格|权限)|令牌|token.{0,12}(无|不|失效)|授权", ans, re.I))
fake_success = bool(re.search(r"(已(经)?(建|创建|完成)[了成]?.{0,10}(表|快递)|(表|快递).{0,8}(已|成功)(建|创建)|成功创建.{0,6}表)", ans))
cli_tried = any("inkreach-teable" in c for c in cmds)
out.append(result("尝试用 CLI 执行任务", cli_tried, [c for c in cmds if "inkreach-teable" in c][:1]))
out.append(result("识别出权限不足并如实说明", perm_ack and not fake_success, ans[:220]))
# 未陷入 403 循环(超过 8 次失败重试视为 flailing)
f403 = sum(1 for c in cmds if re.search(r"inkreach-teable", c))
out.append(result("未陷入无意义重试(命令次数有界)", f403 <= 12, f"cli calls={f403}"))
elif ev_name == "deep-pagination":
tbl13 = cfg.get("TBL13_ID")
if not tbl13:
out.append(result("fixture big_logs 存在", False, "TBL13_ID 未配置"))
return out
recs = (cli_json(cfg, "record", "get", "--table-id", tbl13, "--take", "1000") or {}).get("records") or []
slow = [r for r in recs if str((r.get("fields") or {}).get("订单号", "")).startswith("SLOW-")]
ids = [str((r.get("fields") or {}).get("订单号")) for r in slow]
count_ok = bool(re.search(r"2\s*(条|个|records?|行)", ans))
found_any = any(i in ans for i in ids)
out.append(result("正确给出 SLOW- 记录总数=2", count_ok, f"实际: {ids}"))
out.append(result("答复包含一条真实 SLOW- 记录的订单号", found_any, ans[:200]))
elif ev_name == "vague-daily-notify":
# 模糊需求:每天 + 订单进展 + 站内通知 → scheduledTime + 统计 + sendNotification + active
wfs = cli_json(cfg, "workflow", "list") or []
cands = [w for w in wfs if w.get("isActive")]
hit = None
for w in cands:
wf_full, trs, acs = wf_nodes(cfg, w["id"])
if (trs and trs[0].get("category") == "scheduledTime"
and any(a.get("category") == "sendNotification" for a in acs)):
hit = wf_full
break
out.append(result("建了激活的每日定时工作流(scheduledTime+sendNotification)", hit is not None,
f"active scheduledTime+notify wf: {hit.get('name') if hit else None}"))
if hit:
_, trs, acs = wf_nodes(cfg, hit["id"])
trig = trs[0] if trs else {}
timing = (trig.get("config") or {}).get("timing") or {}
out.append(result("timing 为生效字段(type+interval)", "type" in timing and "interval" in timing, f"timing={timing}"))
has_stats = any(a.get("category") == "script" and re.search(r"record|count|统计|length", (a.get("config") or {}).get("code", "")) for a in acs)
out.append(result("有订单统计逻辑", has_stats, [a.get("category") for a in acs]))
elif ev_name == "field-options-update":
fields = cli_json(cfg, "field", "list", tbl) or []
f = next((x for x in fields if x.get("name") == "结果"), {})
choices = [c.get("name") if isinstance(c, dict) else c for c in ((f.get("options") or {}).get("choices") or [])]
out.append(result("「结果」字段 choices 新增「已取消」", "已取消" in choices, "choices=%s" % choices))
out.append(result("使用 field update --options(走 convert API)", any(re.search(r"field update.*--options|field.*--options", c, re.S) for c in cmds),
[c for c in cmds if "field" in c and "update" in c][:1]))
out.append(result("原有选项未被破坏(成功/失败/处理中 仍在)", all(x in choices for x in ("成功", "失败", "处理中")), "choices=%s" % choices))
return out
def main():
iteration = sys.argv[1]
tag = sys.argv[2]
iteration_dir = ROOT / iteration
for eval_dir in sorted(iteration_dir.glob("eval-*")):
ev_name = eval_dir.name.split("-", 2)[2]
for config_dir in sorted(p for p in eval_dir.iterdir() if p.is_dir()):
run_dir = config_dir / "run-1"
if not (run_dir / "pi.jsonl").exists():
continue
cfg = env_all.get(config_dir.name) or env_all['with_skill']
cfg_short = "W" if config_dir.name == "with_skill" else "O"
tag_cfg = f"{tag}{cfg_short}"
try:
expectations = grade_eval(cfg, ev_name, tag_cfg, run_dir, cfg_short)
except Exception as e:
expectations = [result("判分异常", False, repr(e))]
g = {"expectations": expectations}
(run_dir / "grading.json").write_text(json.dumps(g, indent=2, ensure_ascii=False))
npass = sum(1 for x in expectations if x["passed"])
print(f"{eval_dir.name:38} {config_dir.name:12} {npass}/{len(expectations)}", flush=True)
if __name__ == "__main__":
main()
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Grade multi-turn (correction) evals: final state must satisfy the CORRECTED
requirement; amendment of the existing artifact is preferred over rebuilding.
mt-1 accepted shapes for "结果 变成 失败 才站内通知":
A) recordCondition trigger with `conditions` (state-match reading), OR
B) recordUpdated trigger watching the 结果 field + script gate on 失败
(transition reading — semantically at least as correct)
mt-2: incremental schema (add 渠道 select + mark the existing record).
"""
import json, os, re, subprocess, sys
from pathlib import Path
HARNESS = Path(__file__).parent
ROOT = Path(os.environ.get("EVAL_ROOT", "/root/eval-rerun"))
env_all = json.loads(Path(os.environ.get("EVAL_ENV", str(HARNESS.parent / "sandbox-env.json"))).read_text())
CLI = os.environ.get("EVAL_CLI", "/root/eval-rerun/bin-new/bin/inkreach-teable")
def cli(*a):
env = {**os.environ,
"INKREACH_TEABLE_ENDPOINT": env_all["INKREACH_TEABLE_ENDPOINT"],
"INKREACH_TEABLE_TOKEN": env_all["INKREACH_TEABLE_TOKEN"],
"INKREACH_TEABLE_BASE_ID": env_all["with_skill"]["INKREACH_TEABLE_BASE_ID"]}
p = subprocess.run([CLI, *a, "--json"], capture_output=True, text=True, env=env, timeout=60)
try:
return json.loads(p.stdout)
except json.JSONDecodeError:
return None
def result(t, p, e):
return {"text": t, "passed": bool(p), "evidence": str(e)[:300]}
def cmds_of(rd, turn=2):
out = []
f = rd / f"turn{turn}.jsonl"
if not f.exists():
return out
for line in f.read_text(errors="replace").splitlines():
try:
e = json.loads(line)
except json.JSONDecodeError:
continue
if e.get("type") == "tool_execution_start" and e.get("toolName") == "bash":
out.append(e.get("args", {}).get("command", ""))
return out
def grade_mt1():
out = []
wfs = cli("workflow", "list") or []
if not isinstance(wfs, list):
return [result("workflow list 可读", False, wfs)]
ok = amended = False
for w in wfs:
full = cli("workflow", "get", w["id"]) or {}
trs = [n for n in full.get("nodes", []) if n.get("type") == "trigger"]
acs = [n for n in full.get("nodes", []) if n.get("type") == "action"]
trig = trs[0] if trs else {}
cfg = trig.get("config") or {}
cats = [a.get("category") for a in acs]
shape_a = trig.get("category") == "recordCondition" and "conditions" in cfg
shape_b = (trig.get("category") == "recordUpdated" and cfg.get("fieldIds")
and any(a.get("category") == "script" and re.search(r"失败", (a.get("config") or {}).get("code", "")) for a in acs))
if full.get("isActive") and (shape_a or shape_b):
ok = "sendNotification" in cats and "sendEmail" not in cats
out.append(result("修正后终态:仅在结果变失败时站内通知(recordCondition 或 recordUpdated+门控)", ok,
[w.get("name") for w in wfs]))
c2 = cmds_of(ROOT / "iteration-mt" / "mt-1-notify-correction" / "auto_skill" / "run-1")
amended = any(re.search(r"trigger set|action (update|add|delete)", c) for c in c2) and not any(
re.search(r"workflow create", c) for c in c2)
out.append(result("第二轮是修改现有工作流而非推倒重建", amended,
[c[:80] for c in c2 if "workflow" in c][:3]))
return out
def grade_mt2():
out = []
tables = cli("table", "list") or []
fb = [t for t in tables if "反馈" in t.get("name", "") or "feedback" in t.get("name", "").lower()]
if not fb:
return [result("反馈表存在", False, [t.get("name") for t in tables])]
tid = fb[0]["id"]
flds = cli("field", "list", tid) or []
okf = any(f.get("name") == "内容" for f in flds)
ch = next((f for f in flds if f.get("name") == "渠道"), None)
okc = bool(ch) and set(["微信", "电话", "邮件"]) <= set(
c.get("name") for c in (ch.get("options") or {}).get("choices", []))
recs = (cli("record", "get", "--table-id", tid) or {}).get("records") or []
okr = any((r.get("fields") or {}).get("渠道") == "微信" for r in recs)
out.append(result("第一轮产物保留(表+内容字段)", okf, fb[0]["name"]))
out.append(result("第二轮增量:渠道字段三选项", okc, (ch or {}).get("options")))
out.append(result("示例记录渠道=微信", okr, [(r.get("fields") or {}).get("订单号"), (r.get("fields") or {}).get("渠道")] if not okr else "ok"))
return out
def main():
iteration = sys.argv[1] if len(sys.argv) > 1 else "iteration-mt"
g1 = grade_mt1()
g2 = grade_mt2()
for name, exps in [("mt-1-notify-correction", g1), ("mt-2-incremental-schema", g2)]:
rd = ROOT / iteration / name / "auto_skill" / "run-1"
(rd / "grading.json").write_text(json.dumps({"expectations": exps}, indent=2, ensure_ascii=False))
np = sum(1 for x in exps if x["passed"])
print(f"{name:28} {np}/{len(exps)}")
if __name__ == "__main__":
main()
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Grade plugin-skill eval runs (ported from plugin-workspace, per-config sandboxes)."""
import json, os, re, subprocess, sys
from pathlib import Path
HARNESS = Path(__file__).parent # evals/ itself
ROOT = Path(os.environ.get("EVAL_ROOT", "/root/eval-rerun"))
CLI = os.environ.get("EVAL_CLI", "/root/eval-rerun/bin-new/bin/inkreach-teable")
env_all = json.loads(Path(os.environ.get('EVAL_ENV', str(HARNESS / 'plugin-sandbox-env.json'))).read_text())
FORBIDDEN_IMPORT = re.compile(r"^\s*import\s+(?!type)[^\n]*from\s+['\"]@teable/(sdk|ui-lib|openapi|core|icons)", re.M)
REACT_IMPORT = re.compile(r"^\s*import\s+(?!type)[^\n]*from\s+['\"]react['\"]", re.M)
def cli_json(*args):
e = {**os.environ,
"INKREACH_TEABLE_ENDPOINT": env_all["INKREACH_TEABLE_ENDPOINT"],
"INKREACH_TEABLE_TOKEN": env_all["INKREACH_TEABLE_TOKEN"],
"INKREACH_TEABLE_BASE_ID": env_all["with_skill"]["INKREACH_TEABLE_BASE_ID"]}
p = subprocess.run([CLI, *args, "--json"], capture_output=True, text=True, env=e, timeout=60)
try:
return json.loads(p.stdout)
except json.JSONDecodeError:
return None
def transcript(run_dir: Path):
cmds = []
for line in (run_dir / "pi.jsonl").read_text(errors="replace").splitlines():
try:
ev = json.loads(line)
except json.JSONDecodeError:
continue
if ev.get("type") == "tool_execution_start" and ev.get("toolName") == "bash":
cmds.append(ev.get("args", {}).get("command", ""))
return cmds
def answer(run_dir: Path) -> str:
fa, af = run_dir / "final_answer.md", run_dir / "assistant_final.md"
return fa.read_text(errors="replace") if fa.exists() else af.read_text(errors="replace")
def plugin_sources(run_dir: Path):
files = sorted((run_dir / "plugin").rglob("*.[tj]s*")) or sorted(run_dir.rglob("plugin.json"))
return "\n".join(p.read_text(errors="replace") for p in files), files
def result(text, passed, evidence):
return {"text": text, "passed": bool(passed), "evidence": str(evidence)[:400]}
def grade_eval(ev_name, run_dir: Path, cfg_short: str):
cmds = transcript(run_dir)
ans = answer(run_dir)
out = []
if ev_name in ("contextmenu-plugin", "panel-widget"):
src, files = plugin_sources(run_dir)
want_pos = "contextMenu" if ev_name == "contextmenu-plugin" else "panel"
want_api = "addContextMenuAction" if ev_name == "contextmenu-plugin" else "addPanel"
pj = run_dir / "plugin" / "plugin.json"
manifest_ok = False
if pj.exists():
try:
m = json.loads(pj.read_text())
manifest_ok = bool(re.fullmatch(r"[a-z0-9-]+", m.get("id", ""))) and want_pos in (m.get("positions") or [])
except json.JSONDecodeError:
pass
out.append(result(f"plugin.json 存在且 manifest 合法(id 合规、positions 含 {want_pos})", manifest_ok,
pj.read_text(errors="replace")[:150] if pj.exists() else "plugin/plugin.json 未找到"))
out.append(result("代码从 window.__TEABLE_HOST__ 获取宿主能力", "__TEABLE_HOST__" in src,
"host global: %s" % ("有" if "__TEABLE_HOST__" in src else "无")))
bad = FORBIDDEN_IMPORT.findall(src)
out.append(result("@teable/* 无运行时值导入(仅允许 import type)", not bad, "违规: %r" % bad if bad else "ok"))
uses_react = bool(REACT_IMPORT.search(src))
wp = run_dir / "plugin" / "webpack.config.js"
wp_txt = wp.read_text(errors="replace") if wp.exists() else ""
externals_ok = "__TEABLE_HOST__" in wp_txt and "externals" in wp_txt
out.append(result("react 导入经 externals 映射(或无 react 裸导入)", externals_ok if uses_react else True,
"webpack externals: %s" % ("映射" if externals_ok else ("未映射!" if uses_react else "不需要"))))
out.append(result(f"definePlugin 注册且调用 ctx.{want_api}", "definePlugin" in src and want_api in src,
"definePlugin=%s %s=%s" % ("definePlugin" in src, want_api, want_api in src)))
if ev_name == "contextmenu-plugin":
edits = re.search(r"updateRecords|record.*update|OpenAPI", src, re.I)
out.append(result("实现了批量更新记录逻辑", bool(edits), edits.group(0) if edits else "未发现"))
else:
sel = re.search(r"useSelection|selection|getSelectionRecords", src, re.I)
out.append(result("通过宿主 SDK 获取选中记录", bool(sel), sel.group(0) if sel else "未发现"))
elif ev_name == "deploy-chain":
plugin_id = f"eval-deploy-{cfg_short}"
up = any("upload-bundle" in c for c in cmds)
out.append(result("调用了 upload-bundle 上传", up, [c for c in cmds if "upload-bundle" in c][:1]))
enabled = any("plugin-config" in c for c in cmds)
out.append(result("启用了 plugin-config", enabled, [c for c in cmds if "plugin-config" in c][:1]))
installed = any(re.search(r"context-menu.*install|plugin-context-menu", c) for c in cmds)
out.append(result("安装到表级 context-menu 位置", installed, [c for c in cmds if "context-menu" in c][:1]))
cleaned = any(("DELETE" in c.upper() and "/api/plugin/" in c) or re.search(r"plugin uninstall|plugin-install", c) for c in cmds) \
or (cli_json("plugin", "list") or [] and not any(p.get("id") == plugin_id for p in (cli_json("plugin", "list") or [])) and any("install" in c for c in cmds))
out.append(result("执行了清理(卸载安装/删除插件)", cleaned, [c for c in cmds if "uninstall" in c or "DELETE" in c.upper()][:2]))
lst = json.dumps(cli_json("plugin", "list") or [], ensure_ascii=False)
out.append(result("实例上无插件残留(plugin list 不含该 id)", plugin_id not in lst,
"残留!" if plugin_id in lst else "干净"))
verify = re.search(r"已确认|确认(安装|清理|删除)|installed|verified|清理(完成|干净)", ans)
out.append(result("答复确认了安装与清理结果", bool(verify), ans[:200]))
elif ev_name == "proxy-rule":
out.append(result("方案使用同源代理 /api/plugin-proxy/<service>/...", "/api/plugin-proxy/" in ans, ans[:250]))
out.append(result("指出需注册 PLUGIN_API_SERVICES 并重启 teable-backend", "PLUGIN_API_SERVICES" in ans,
"提及" if "PLUGIN_API_SERVICES" in ans else "未提及"))
lan_fetch = re.search(r"fetch\(\s*['\"]http://192\.168", ans)
out.append(result("不建议在 bundle 里直接请求局域网 IP", lan_fetch is None, lan_fetch.group(0) if lan_fetch else "ok"))
elif ev_name == "pitfall-diagnosis":
cause = re.search(r"(@teable/sdk|值导入|运行时导入|externals|undefined)|import", ans) and re.search(r"__TEABLE_HOST__|宿主全局|host global", ans)
out.append(result("定位为 @teable/* 运行时导入被 externals 映射为 undefined", bool(cause), ans[:250]))
out.append(result("给出修复:改从 window.__TEABLE_HOST__ 读取(或仅 import type)", "__TEABLE_HOST__" in ans, ans[:200]))
elif ev_name == "native-form-routing":
nf = re.search(r"NativeForm|useNativeForm|NativeFormScope", ans)
out.append(result("指出原生表单注入能力(NativeForm/NativeFormScope/useNativeForm)", bool(nf),
nf.group(0) if nf else ans[:200]))
# 咨询类任务应给出可实现方案(两档之一 + 关键代码/props)
plan = bool(nf) and bool(re.search(r"visibleFieldIds|slots|NativeFormField|formId|interceptSubmit", ans))
out.append(result("给出了具体实现要点(props/多步结构)", plan, ans[:250]))
return out
def main():
iteration_dir = ROOT / sys.argv[1]
for eval_dir in sorted(iteration_dir.glob("eval-*")):
ev_name = eval_dir.name.split("-", 2)[2]
for config_dir in sorted(p for p in eval_dir.iterdir() if p.is_dir()):
run_dir = config_dir / "run-1"
if not (run_dir / "pi.jsonl").exists():
continue
cfg_short = "W" if config_dir.name == "with_skill" else "O"
try:
expectations = grade_eval(ev_name, run_dir, cfg_short)
except Exception as e:
expectations = [result("判分异常", False, repr(e))]
(run_dir / "grading.json").write_text(json.dumps({"expectations": expectations}, indent=2, ensure_ascii=False))
npass = sum(1 for x in expectations if x["passed"])
print(f"{eval_dir.name:34} {config_dir.name:12} {npass}/{len(expectations)}", flush=True)
if __name__ == "__main__":
main()
+41
View File
@@ -0,0 +1,41 @@
{
"skill_name": "inkreach-teable-plugin",
"evals": [
{
"id": 1,
"name": "contextmenu-plugin",
"prompt": "帮我写一个右键菜单插件「批量置成功」:在表格里选中若干记录后右键,一键把这些记录的「结果」字段全部改成「成功」。把完整可构建的项目文件写到当前目录 plugin/ 子目录(plugin.json、webpack.config.js、src/…),手写即可,不必运行构建,也不用部署。",
"expected_output": "manifest 含 id/positions contextMenu;代码从 window.__TEABLE_HOST__ 取 SDK/OpenAPI/React;@teable/* 仅 import type 或不出现(禁止运行时值导入);definePlugin + ctx.addContextMenuAction;webpack externals 把 react 映射到 host 全局。"
},
{
"id": 2,
"name": "panel-widget",
"prompt": "写一个侧边面板插件「选中统计」:打开面板时实时显示当前表名和当前选中记录数量。项目文件写到 plugin/ 子目录,手写即可,不必构建部署。",
"expected_output": "positions panel;ctx.addPanel;选中数与表名来自 host SDK(useSelection/useTableId 等,经 window.__TEABLE_HOST__);无禁止的运行时导入。"
},
{
"id": 3,
"name": "deploy-chain",
"prompt": "当前目录 ready-plugin/ 下是我准备好的插件(plugin.json + dist/main.js)。把它部署到实例并安装到 {TBL} 这张表的右键菜单,确认安装成功;然后完整清理:卸载安装、停用、删除插件,确认都清干净。",
"expected_output": "upload-bundle(201)→ plugin-config 启用 → context-menu install → 验证;然后卸载安装 + DELETE /api/plugin/:id;plugin list 确认无残留。"
},
{
"id": 4,
"name": "proxy-rule",
"prompt": "我的插件需要调用我局域网的后端服务 http://192.168.124.92:8001/api/track 拿数据,实例是公网访问的(table.inkreach.cc),公网用户也要能用。插件里的请求应该怎么写?需要在实例侧改什么?给出说明和关键代码。",
"expected_output": "fetch('/api/plugin-proxy/<service>/api/track') 同源代理;注册 PLUGIN_API_SERVICES(两个 env 文件)+ 重启 teable-backend;不在 bundle 里硬编码局域网 IP;外部源需 manifest apiOrigins。"
},
{
"id": 5,
"name": "pitfall-diagnosis",
"prompt": "我上传的插件右键菜单能出现,但点击就白屏,控制台报:Cannot read properties of undefined (reading 'useFields')。帮我定位原因并给出修复。",
"expected_output": "定位为运行时从 @teable/sdk 等 import(被 webpack externals 映射成 undefined)/未从 window.__TEABLE_HOST__ 读取;修复=改用 host 全局取 SDK 或仅 import type。"
},
{
"id": 6,
"name": "native-form-routing",
"prompt": "我想在插件里做一个自定义布局的多步表单页,每一步嵌入系统原生的字段编辑器(不用自己重写输入组件),能做到吗?怎么实现?",
"expected_output": "指出用原生表单注入能力:NativeForm / NativeFormScope / useNativeForm(sdk-api.md 有专门章节);说明分层(Tier-1 注入/Tier-2 kit)。"
}
]
}
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""Launch inkreach-teable skill evals (iteration-4 A/B rerun).
Usage: python3 run_evals4.py <iteration-name> <tag> <config-spec> [eval-ids]
config-spec: with_skill=/root/eval-rerun/skill-new:/root/eval-rerun/bin-new/bin,old_skill=/root/eval-rerun/skill-old:/root/eval-rerun/bin-old/bin
Differences vs the original run_evals.py (eval-integrity fixes, see LESSONS.md):
- evals.json + sandbox-env.json live in THIS harness dir (outside the agent's cwd tree)
- per-config sandbox BASE via INKREACH_TEABLE_BASE_ID (no cross-config contamination)
- per-config PATH prepended with that config's CLI version dir (0.1.4 vs 0.1.5)
- pi runs with an isolated HOME (no user-level skills discovered)
- grading.json written only afterwards by grade_evals4.py (no answers near agents)
"""
import json
import os
import re
import subprocess
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
HARNESS = Path(__file__).parent # evals/ itself
ROOT = Path(os.environ.get("EVAL_ROOT", "/root/eval-rerun"))
MODEL = os.environ.get("EVAL_MODEL", "opencode-go/deepseek-v4.1-flash")
RUN_TIMEOUT = 600
PI_HOME = os.environ.get("EVAL_PI_HOME", "/root/eval-rerun/pi-home")
env_all = json.loads(Path(os.environ.get('EVAL_ENV', str(HARNESS / 'sandbox-env.json'))).read_text())
PROMPT_TMPL = """你在操作一个已部署的 inkreach-teable 实例(Teable fork,自研自动化引擎)。
环境(已配置好,直接使用):
- CLI 命令: `inkreach-teable`(已在 PATH)。endpoint / token / base 已通过环境变量 INKREACH_TEABLE_ENDPOINT / INKREACH_TEABLE_TOKEN / INKREACH_TEABLE_BASE_ID 注入,不要执行 auth login,不要改配置文件。
- 只操作环境变量指定的这个基地(评估沙箱);严禁触碰其他基地。
- 工作目录就是当前目录,把中间产物写在这里。
{SKILL_LINE}
任务:{TASK}
要求:
1. 逐步执行任务,用 CLI 完成实际操作(不是只给命令)。
2. 完成后把最终答复写入 final_answer.md:做了什么、关键命令、结果、需要用户知道的事项。"""
def extract_final_text(jsonl_path: Path) -> str:
texts = []
for line in jsonl_path.read_text(errors="replace").splitlines():
line = line.strip()
if not line:
continue
try:
ev = json.loads(line)
except json.JSONDecodeError:
continue
if ev.get("type") == "turn_end" and ev.get("message", {}).get("role") == "assistant":
for c in ev["message"].get("content", []):
if c.get("type") == "text" and c.get("text", "").strip():
texts.append(c["text"])
return texts[-1] if texts else ""
def extract_usage(jsonl_path: Path) -> dict:
usage = {}
for line in jsonl_path.read_text(errors="replace").splitlines():
m = re.search(r'"usage":\{[^{}]*"totalTokens":(\d+)', line)
if m:
usage["totalTokens"] = int(m.group(1))
return usage
def run_one(task, config, skill_path, bin_dir, cfg_env, run_dir: Path):
run_dir.mkdir(parents=True, exist_ok=True)
if config == "auto_skill":
# trigger test: no skill hint at all; the skill must be DISCOVERED from
# the user-level skills dir of the isolated (skilled) HOME.
skill_line = ""
elif skill_path:
skill_line = (
f"- 先完整阅读技能说明 {skill_path}/SKILL.md 并严格按其路由规则与关键约束操作;\n"
f" 需要细节时再按其中链接查阅 guides/ 与 api-reference/ 文档。"
)
else:
skill_line = "- 环境里没有额外文档,自行判断。"
prompt = PROMPT_TMPL.format(SKILL_LINE=skill_line, TASK=task)
(run_dir / "prompt.txt").write_text(prompt)
full_env = {
**os.environ,
"HOME": (os.environ.get("EVAL_PI_HOME_SKILLED", "/root/eval-rerun/pi-home-skilled") if config == "auto_skill" else PI_HOME),
"PATH": f"{bin_dir}:{os.environ['PATH']}",
**{k: env_all[k] for k in ("INKREACH_TEABLE_ENDPOINT", "INKREACH_TEABLE_TOKEN")},
**cfg_env,
}
cmd = ["timeout", str(RUN_TIMEOUT), "pi", "-p", "--mode", "json", "--model", MODEL, prompt]
t0 = time.time()
with open(run_dir / "pi.jsonl", "w") as out:
proc = subprocess.run(cmd, stdout=out, stderr=subprocess.DEVNULL, cwd=run_dir, env=full_env)
dur = time.time() - t0
timing = {"duration_ms": int(dur * 1000), "total_duration_seconds": round(dur, 1),
"total_tokens": extract_usage(run_dir / "pi.jsonl").get("totalTokens"),
"exit_code": proc.returncode, "cli": Path(bin_dir).name, "config": config}
(run_dir / "timing.json").write_text(json.dumps(timing, indent=2))
(run_dir / "assistant_final.md").write_text(extract_final_text(run_dir / "pi.jsonl") or "(no final text captured)")
return config, run_dir.name, proc.returncode, round(dur, 1)
def main():
iteration = sys.argv[1]
tag = sys.argv[2]
iteration_dir = ROOT / iteration
configs = {}
for part in sys.argv[3].split(","):
name, _, rest = part.partition("=")
skill_path, _, bin_dir = rest.partition(":")
configs[name] = (skill_path, bin_dir)
evals = json.loads(Path(os.environ.get('EVAL_EVALS', str(HARNESS / 'teable-evals.json'))).read_text())["evals"]
if len(sys.argv) > 4 and sys.argv[4]:
only = {int(x) for x in sys.argv[4].split(",")}
evals = [e for e in evals if e["id"] in only]
jobs = []
for ev in evals:
name = f"eval-{ev['id']}-{ev['name']}"
for config, (skill_path, bin_dir) in configs.items():
cfg = env_all.get(config) or env_all['with_skill']
cfg_short = "W" if config == "with_skill" else "O"
task = (ev["prompt"]
.replace("{TAG}", f"{tag}{cfg_short}")
.replace("{WFID}", cfg["WF_ID"])
.replace("{TBL5}", cfg["TBL5_NAME"])
.replace("{ORD}", cfg["ORD"]))
meta = {"eval_id": ev["id"], "eval_name": ev["name"], "config": config, "prompt": task, "assertions": []}
rd = iteration_dir / name / config / "run-1"
rd.parent.mkdir(parents=True, exist_ok=True)
(rd.parent / "eval_metadata.json").write_text(json.dumps(meta, indent=2, ensure_ascii=False))
cfg_env = {
"INKREACH_TEABLE_BASE_ID": cfg["INKREACH_TEABLE_BASE_ID"],
"INKREACH_TEABLE_EVAL_TBL": cfg["TBL5_ID"],
"INKREACH_TEABLE_EVAL_ORD": cfg["ORD"],
}
if ev["id"] == 12:
cfg_env["INKREACH_TEABLE_TOKEN"] = env_all["LOWPERM_TOKEN"]
jobs.append((task, config, skill_path, bin_dir, cfg_env, rd))
print(f"{len(jobs)} runs -> {iteration_dir}", flush=True)
with ThreadPoolExecutor(max_workers=6) as ex:
futs = [ex.submit(run_one, *j) for j in jobs]
for f in futs:
print(f.result(), flush=True)
if __name__ == "__main__":
main()
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Multi-turn evals: two-round conversations where turn 2 corrects turn 1.
Verifies the agent AMENDS the previous artifacts (edit workflow / add field)
instead of ignoring the correction or rebuilding blindly.
Usage: python3 run_multiturn.py <iteration> <tag>
"""
import json, os, subprocess, sys, time
from pathlib import Path
HARNESS = Path(__file__).parent
ROOT = Path(os.environ.get("EVAL_ROOT", "/root/eval-rerun"))
MODEL = os.environ.get("EVAL_MODEL", "opencode-go/deepseek-v4.1-flash")
RUN_TIMEOUT = 480
PI_HOME = os.environ.get("EVAL_PI_HOME_SKILLED", "/root/eval-rerun/pi-home-skilled") # trigger mode: skill auto-discovered
env_all = json.loads((HARNESS / "sandbox-env.json").read_text())
TASKS = [
{
"name": "mt-1-notify-correction",
"turn1": "帮我建个自动化:order_logs 表有新记录的时候通知我。名字你来定,配好激活。",
"turn2": "不对,改一下:别用邮件,要站内通知;而且只在「结果」字段变成「失败」的时候才通知,不是有新记录就通知。",
"turn1_hint": None,
},
{
"name": "mt-2-incremental-schema",
"turn1": "帮我建张表存客户反馈,加个单行文本「内容」字段,随便插一条示例。",
"turn2": "再补一个单选字段「渠道」,选项:微信/电话/邮件。然后把刚才那条示例记录的渠道标成「微信」。",
"turn1_hint": None,
},
]
WRAP = """要求:
1. 逐步用 CLI 完成实际操作(不是只给命令)。
2. 每轮结束后把当前轮的答复写入 final_answer_turn{N}.md(做了什么、关键命令、结果)。
3. 用户的后续修正优先于之前的做法;能改就改现有的,不要无视."""
def turn(run_dir: Path, sid: str, text: str, n: int):
prompt = f"{text}\n\n{WRAP.format(N=n)}"
cmd = ["timeout", str(RUN_TIMEOUT), "pi", "-p", "--mode", "json",
"--session-id", sid, "--model", MODEL, prompt]
out_f = run_dir / f"turn{n}.jsonl"
env = {
**os.environ, "HOME": PI_HOME,
"PATH": f"/root/eval-rerun/bin-new/bin:{os.environ['PATH']}",
"INKREACH_TEABLE_ENDPOINT": env_all["INKREACH_TEABLE_ENDPOINT"],
"INKREACH_TEABLE_TOKEN": env_all["INKREACH_TEABLE_TOKEN"],
"INKREACH_TEABLE_BASE_ID": env_all["with_skill"]["INKREACH_TEABLE_BASE_ID"],
}
with open(out_f, "w") as out:
p = subprocess.run(cmd, stdout=out, stderr=subprocess.DEVNULL, cwd=run_dir, env=env)
# pull last assistant text
last = ""
for line in out_f.read_text(errors="replace").splitlines():
try:
ev = json.loads(line)
except json.JSONDecodeError:
continue
if ev.get("type") == "turn_end" and ev.get("message", {}).get("role") == "assistant":
for c in ev["message"].get("content", []):
if c.get("type") == "text" and c.get("text", "").strip():
last = c["text"]
(run_dir / f"final_answer_turn{n}.md").write_text(last or "(none)")
return p.returncode
def main():
iteration, tag = sys.argv[1], sys.argv[2]
for t in TASKS:
rd = ROOT / iteration / t["name"] / "auto_skill" / "run-1"
rd.mkdir(parents=True, exist_ok=True)
sid = f"mt-{tag}-{t['name']}"
t0 = time.time()
r1 = turn(rd, sid, t["turn1"], 1)
r2 = turn(rd, sid, t["turn2"], 2)
print(f"{t['name']}: turn1 exit={r1} turn2 exit={r2} dur={round(time.time()-t0,1)}s", flush=True)
if __name__ == "__main__":
main()
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Launch inkreach-teable-plugin skill evals (integrity-clean port).
Usage: python3 run_plugin_evals.py <iteration> <tag> <config-spec> [eval-ids]
config-spec: with_skill=/root/eval-rerun/skill-plugin-new:/root/eval-rerun/bin-new/bin
"""
import json, os, re, shutil, subprocess, sys, time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
HARNESS = Path(__file__).parent # evals/ itself
ROOT = Path(os.environ.get("EVAL_ROOT", "/root/eval-rerun"))
MODEL = os.environ.get("EVAL_MODEL", "opencode-go/deepseek-v4.1-flash")
RUN_TIMEOUT = 900 # plugin evals build bundles — need more headroom
PI_HOME = os.environ.get("EVAL_PI_HOME", "/root/eval-rerun/pi-home")
FIXTURE = "/root/pi-skill-lab/plugin-workspace/fixture-deploy-W"
env_all = json.loads(Path(os.environ.get('EVAL_ENV', str(HARNESS / 'plugin-sandbox-env.json'))).read_text())
PROMPT_TMPL = """你在为一套已部署的 inkreach-teable 实例(Teable fork)开发/部署动态 React 插件。
环境(已配置好,直接使用):
- 后端 http://localhost:3003,前端 http://localhost:3002。PAT 已通过环境变量 INKREACH_TEABLE_ENDPOINT / INKREACH_TEABLE_TOKEN 注入(`inkreach-teable` CLI 可直接用)。
- 评估沙箱基地 ID:{BASE}。凡涉及实例的读写一律只在这个基地内,严禁触碰其他基地;严禁向实例的插件注册表上传与任务无关的演示插件。
- 工作目录就是当前目录,所有产出写在这里。
{SKILL_LINE}
任务:{TASK}
要求:
1. 按任务性质完成:咨询类问题(能不能/怎么做)直接给出方案与关键代码即可,不必搭建完整工程;开发/部署类任务才需要实际构建与部署。
2. 完成后把最终答复写入 final_answer.md:结论、关键实现/命令、结果、需要用户知道的事项。"""
def extract_final_text(p: Path) -> str:
texts = []
for line in p.read_text(errors="replace").splitlines():
try:
ev = json.loads(line)
except json.JSONDecodeError:
continue
if ev.get("type") == "turn_end" and ev.get("message", {}).get("role") == "assistant":
for c in ev["message"].get("content", []):
if c.get("type") == "text" and c.get("text", "").strip():
texts.append(c["text"])
return texts[-1] if texts else ""
def run_one(task, config, skill_path, bin_dir, cfg_env, run_dir: Path):
run_dir.mkdir(parents=True, exist_ok=True)
skill_line = (
f"- 先完整阅读技能说明 {skill_path}/SKILL.md 并按其模块地图(按需读 dynamic-plugins/ 下文档)操作。"
if skill_path else ""
)
prompt = PROMPT_TMPL.format(BASE=cfg_env["INKREACH_TEABLE_BASE_ID"], SKILL_LINE=skill_line, TASK=task)
(run_dir / "prompt.txt").write_text(prompt)
full_env = {
**os.environ, "HOME": PI_HOME, "PATH": f"{bin_dir}:{os.environ['PATH']}",
**{k: env_all[k] for k in ("INKREACH_TEABLE_ENDPOINT", "INKREACH_TEABLE_TOKEN")},
**cfg_env,
}
cmd = ["timeout", str(RUN_TIMEOUT), "pi", "-p", "--mode", "json", "--model", MODEL, prompt]
t0 = time.time()
with open(run_dir / "pi.jsonl", "w") as out:
proc = subprocess.run(cmd, stdout=out, stderr=subprocess.DEVNULL, cwd=run_dir, env=full_env)
(run_dir / "timing.json").write_text(json.dumps(
{"duration_s": round(time.time() - t0, 1), "exit_code": proc.returncode, "config": config}))
(run_dir / "assistant_final.md").write_text(extract_final_text(run_dir / "pi.jsonl") or "(none)")
return config, run_dir.name, proc.returncode
def main():
iteration, tag = sys.argv[1], sys.argv[2]
iteration_dir = ROOT / iteration
configs = {}
for part in sys.argv[3].split(","):
name, _, rest = part.partition("=")
sp, _, bd = rest.partition(":")
configs[name] = (sp, bd)
evals = json.loads(Path(os.environ.get('EVAL_EVALS', str(HARNESS / 'plugin-evals.json'))).read_text())["evals"]
if len(sys.argv) > 4 and sys.argv[4]:
only = {int(x) for x in sys.argv[4].split(",")}
evals = [e for e in evals if e["id"] in only]
jobs = []
for ev in evals:
name = f"eval-{ev['id']}-{ev['name']}"
for config, (skill_path, bin_dir) in configs.items():
cfg = env_all[config]
cfg_short = "W" if config == "with_skill" else "O"
task = ev["prompt"].replace("{TBL}", cfg["TBL"])
rd = iteration_dir / name / config / "run-1"
rd.parent.mkdir(parents=True, exist_ok=True)
# eval-3 needs the ready-plugin fixture bundle in cwd
if ev["id"] == 3:
rp = rd / "ready-plugin"
if rp.exists():
shutil.rmtree(rp)
shutil.copytree(FIXTURE, rp)
(rd.parent / "eval_metadata.json").write_text(json.dumps(
{"eval_id": ev["id"], "prompt": task, "assertions": []}, ensure_ascii=False))
cfg_env = {"INKREACH_TEABLE_BASE_ID": cfg["INKREACH_TEABLE_BASE_ID"]}
jobs.append((task, config, skill_path, bin_dir, cfg_env, rd))
print(f"{len(jobs)} runs -> {iteration_dir}", flush=True)
with ThreadPoolExecutor(max_workers=3) as ex: # builds are heavy — 3 workers
for f in [ex.submit(run_one, *j) for j in jobs]:
print(f.result(), flush=True)
if __name__ == "__main__":
main()
+86
View File
@@ -0,0 +1,86 @@
{
"skill_name": "inkreach-teable",
"evals": [
{
"id": 1,
"name": "button-click-automation",
"prompt": "帮我在 order_logs 表里加一个按钮字段(名字用:催单按钮{TAG}),点这个按钮的时候触发一个自动化,自动化里跑一段脚本,脚本返回当前记录的订单号。然后激活它,创建一条新记录并点一下按钮测试,确认执行结果。",
"expected_output": "创建 button 字段(options.workflow 为对象 {id,...})、workflow、buttonClick 触发器、读取 triggerInput 的 script action、activate、fire button-click 测试、runs 确认 succeeded。"
},
{
"id": 2,
"name": "scheduled-digest",
"prompt": "我想要每天定时跑一次自动化(工作流名字用:每日统计{TAG}),统计 order_logs 表里的记录数并输出。帮我配好。",
"expected_output": "scheduledTime 触发器只配 timing.type + timing.interval(后端忽略其他时间字段);script/getRecords action;activate。"
},
{
"id": 3,
"name": "record-condition-email",
"prompt": "当 order_logs 表里有记录的「结果」字段变成「失败」时,自动发邮件通知 ops@xingchen.tech(工作流名字用:失败告警{TAG})。帮我配好。",
"expected_output": "recordCondition 触发器(分类是 recordCondition,条件键名是 conditions 而非 filter);sendEmail action 收件人 ops@xingchen.tech;activate。"
},
{
"id": 4,
"name": "debug-failed-run",
"prompt": "我有个自动化 wfl5F8dQRDloo28w909……不对,ID 是 {WFID},名字叫 nightly-sync,最近好像没正常跑,帮我看看是什么情况,别急着改,先诊断清楚再说。",
"expected_output": "先 workflow get 查 isActive → runs list --status failed → runs get 看 nodeResults 错误;指出脚本抛错(boom: upstream sync timeout);未盲目重新激活。"
},
{
"id": 5,
"name": "select-constraint-write",
"prompt": "在 {TBL5} 表里,把订单号为 {ORD} 的那条记录的「结果」改成「已完成」。另外告诉我这个「结果」字段目前一共有哪些可选值。",
"expected_output": "先查约束发现「已完成」不在 choices;要么说明并停下,要么先加选项再写入;不发生静默失败。"
},
{
"id": 6,
"name": "official-cli-near-miss",
"prompt": "用 teable 的 CLI 工具帮我列出这个基地里都有哪些表。",
"expected_output": "路由到 inkreach-teable CLI(官方 teable CLI 在该实例自动化契约不兼容);列出 order_logs;未安装/调用官方 teable CLI 打实例。"
},
{
"id": 7,
"name": "webhook-receiver",
"prompt": "帮我建一个自动化(名字:回单通知{TAG}),用 webhook 触发:外部系统会 POST 调用它。建好激活后,把可以调用的完整 URL 给我,并实际测试一次确认能通。",
"expected_output": "webhook 触发器;URL 形如 {endpoint}/api/webhook/{baseId}/{workflowId};用 fire webhook 或 curl 实测;已激活。"
},
{
"id": 8,
"name": "weekday-schedule-trap",
"prompt": "我要一个自动化(名字:工作日盘点{TAG}),每个工作日下午5点跑一次,统计 order_logs 表的记录条数就行。帮我配好。",
"expected_output": "知道后端只认 timing.type+interval:要么明确告知『具体时刻/星期』不被支持并给出周期间隔替代方案;绝不悄悄配 weekdays/triggerTime 假装生效。timing 配置无被忽略字段。"
},
{
"id": 9,
"name": "secret-env-usage",
"prompt": "建一个自动化(名字:汇率查询{TAG}),脚本里调用外部汇率 API,API key 是 sk-test-abc123,不要明文写在脚本里。配好激活即可,不用真跑通外部接口。",
"expected_output": "secret set 存 key(如 EXCHANGE_KEY)→ 脚本读 process.env.EXCHANGE_KEY;代码中不出现明文 key;激活。"
},
{
"id": 10,
"name": "script-npm-deps",
"prompt": "我有个自动化脚本要用 dayjs 处理日期格式化。帮我建个演示工作流(名字:日期演示{TAG}),脚本里 require dayjs 并格式化当前日期输出,把依赖正确声明好。",
"expected_output": "action add/update 带 --dependencies '[{\"name\":\"dayjs\",\"version\":...}]'(或 dayjs@版本);知晓后端需 npm 在 service PATH(风险提示)。"
},
{
"id": 11,
"name": "field-options-update",
"prompt": "把 order_logs 表「结果」字段加一个新选项「已取消」。就这一件事。",
"expected_output": "field update --options(走 convert API;普通 PATCH 会丢 options);验证 choices 里出现「已取消」。"
},
{
"id": 12,
"name": "lowperm-403-handling",
"prompt": "帮我在当前基地里建一张新表,名字叫快递跟踪,加个单行文本字段 运单号,再加个数字字段 金额。建好后随便插一条示例数据。"
},
{
"id": 13,
"name": "deep-pagination",
"prompt": "big_logs 这张表里记录挺多的。帮我把订单号以 SLOW- 开头的记录全部找出来:一共几条?再挑其中一条,把它的完整字段值原样发我。"
},
{
"id": 14,
"name": "vague-daily-notify",
"prompt": "我想大概每天知道一下订单进展。邮件先不用,站内通知就行。你看着帮我弄一个。"
}
]
}
+11 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@inkreach/teable-cli", "name": "@inkreach/inkreach-teable",
"version": "0.1.0", "version": "0.2.0",
"description": "CLI (command: inkreach-teable) for operating inkreach-teable: tables, fields, records, views, and the self-built automation engine (8 triggers + 7 actions).", "description": "CLI (command: inkreach-teable) for operating inkreach-teable: tables, fields, records, views, and the self-built automation engine (8 triggers + 7 actions).",
"license": "MIT", "license": "MIT",
"type": "commonjs", "type": "commonjs",
@@ -12,15 +12,22 @@
"node": ">=18" "node": ">=18"
}, },
"bin": { "bin": {
"inkreach-teable": "./dist/bin.js" "inkreach-teable": "dist/bin.js"
}, },
"main": "dist/bin.js", "main": "dist/bin.js",
"files": [
"dist",
"skills",
"docs"
],
"scripts": { "scripts": {
"build": "tsc -p tsconfig.build.json", "build": "tsc -p tsconfig.build.json",
"dev": "tsc -p tsconfig.build.json --watch", "dev": "tsc -p tsconfig.build.json --watch",
"typecheck": "tsc -p tsconfig.json --noEmit", "typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run", "test": "vitest run",
"clean": "rimraf dist tsconfig.build.tsbuildinfo" "clean": "rimraf dist tsconfig.build.tsbuildinfo",
"prepare": "npm run build",
"prepublishOnly": "npm run build && npm test && bash scripts/smoke-commands.sh"
}, },
"dependencies": { "dependencies": {
"commander": "^12.1.0" "commander": "^12.1.0"
+263
View File
@@ -0,0 +1,263 @@
#!/usr/bin/env bash
# Full-command smoke suite — exercise EVERY inkreach-teable CLI command against a
# throwaway sandbox base and assert none of them hit:
# 1. phantom routes (HTTP_404 / "Cannot GET|POST|PUT|PATCH|DELETE")
# 2. CLI syntax bugs ("unknown option" / "unknown command" / "Missing required")
# 3. CLI request-shape (zod "expected X, received Y" validation errors)
# Domain-level 4xx (e.g. "Invalid value for field") are NOT failures — the route
# exists and the backend answered.
#
# Usage:
# INKREACH_TEABLE_ENDPOINT=http://localhost:3003 INKREACH_TEABLE_TOKEN=<PAT> \
# bash scripts/smoke-commands.sh
# Reads the CLI's own config if env vars are absent. Creates + deletes a base
# named "cli-smoke-<timestamp>"; safe to run against production.
set -u
CLI_DIR="$(cd "$(dirname "$0")/.." && pwd)"
CLI="node $CLI_DIR/dist/bin.js"
EP="${INKREACH_TEABLE_ENDPOINT:-$(python3 -c "import json;print(json.load(open('$HOME/.inkreach-teable/config.json')).get('endpoint',''))" 2>/dev/null || true)}"
TOK="${INKREACH_TEABLE_TOKEN:-$(python3 -c "import json;print(json.load(open('$HOME/.inkreach-teable/config.json')).get('token',''))" 2>/dev/null || true)}"
[ -z "$EP" ] || [ -z "$TOK" ] && { echo "need INKREACH_TEABLE_ENDPOINT/TOKEN (or ~/.inkreach-teable/config.json)"; exit 2; }
export INKREACH_TEABLE_ENDPOINT="$EP" INKREACH_TEABLE_TOKEN="$TOK"
FAILPAT='Cannot (GET|POST|PUT|PATCH|DELETE)|HTTP_404|unknown option|unknown command|Missing required|expected [a-z]+, received'
PASS=0; FAIL=0; FAILED_CASES=()
smoke() { # smoke <label> <cmd...>
local label="$1"; shift
local out
out=$("$@" 2>&1)
if echo "$out" | grep -qE "$FAILPAT"; then
FAIL=$((FAIL+1)); FAILED_CASES+=("$label")
echo "FAIL $label"
echo "$out" | grep -E "$FAILPAT" | head -2 | sed 's/^/ /'
else
PASS=$((PASS+1))
[ "${SMOKE_VERBOSE:-0}" = 1 ] && echo "pass $label"
fi
}
j() { python3 -c "import sys,json;d=json.load(sys.stdin);print(eval(sys.argv[1]))" "$1"; }
# ---------- sandbox ----------
SPACE=$(curl -s -H "Authorization: Bearer $TOK" "$EP/api/space" | python3 -c "
import sys,json
for sp in json.load(sys.stdin):
if 'agent-playground' in sp.get('name',''): print(sp['id']); break")
BASE=$(curl -s -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-X POST "$EP/api/base" -d "{\"spaceId\":\"$SPACE\",\"name\":\"cli-smoke-$(date +%s)\"}" | j "d['id']")
export INKREACH_TEABLE_BASE_ID="$BASE"
cleanup() { curl -s -o /dev/null -X DELETE -H "Authorization: Bearer $TOK" "$EP/api/base/$BASE"; }
trap cleanup EXIT
echo "sandbox base: $BASE"
# ---------- auth / base / space ----------
smoke "auth status" $CLI auth status
smoke "space list" $CLI space list
SB2=$($CLI base create --space-id "$SPACE" --name cli-smoke-b2 --json | j "d['id']")
smoke "base create (CLI)" $CLI base get "$SB2"
smoke "base delete (CLI)" $CLI base delete "$SB2"
smoke "auth token list" $CLI auth token list
smoke "base list" $CLI base list
smoke "base get" $CLI base get "$BASE"
# ---------- table ----------
TBL=$($CLI table create --name smoke_t --fields \
'[{"name":"Title","type":"singleLineText"},{"name":"Status","type":"singleSelect","options":{"choices":["todo","done"]}},{"name":"Num","type":"number"}]' \
--json | j "d['id']")
smoke "table create" $CLI table get "$TBL"
smoke "table list" $CLI table list
smoke "table get" $CLI table get "$TBL"
smoke "table get --base-id" $CLI table get "$TBL" --base-id "$BASE"
smoke "table update" $CLI table update "$TBL" --name smoke_t2 --base-id "$BASE"
# ---------- field ----------
smoke "field list (positional)" $CLI field list "$TBL"
smoke "field list --table-id" $CLI field list --table-id "$TBL"
smoke "field list --with-options" $CLI field list "$TBL" --with-options
smoke "field get (name)" $CLI field get "$TBL" Status
smoke "field get --table-id" $CLI field get --table-id "$TBL" Status
smoke "field create" $CLI field create --table-id "$TBL" --name Extra --type singleLineText
smoke "field update --options" $CLI field update "$TBL" Status --options '{"choices":["todo","done","archived"]}'
smoke "field delete" $CLI field delete "$TBL" Extra
# ---------- record ----------
smoke "field create (attachment)" $CLI field create --table-id "$TBL" --name Files --type attachment
smoke "record create --fields" $CLI record create --table-id "$TBL" --fields '{"Title":"r1","Status":"todo","Num":1}'
# Attachment fields accept http(s) urls: string form needs --typecast, object
# form works without. Downloads a real file from the local gitea; a network
# failure degrades to a warning (feature check, not a route-shape check).
ATT_URL='https://gitea.inkreach.cc/inkreachhub/inkreach-teable-cli/raw/branch/main/README.md'
ATT_OUT=$($CLI record create --table-id "$TBL" --typecast --fields "{\"Title\":\"att1\",\"Files\":\"$ATT_URL\"}" --json 2>&1)
if echo "$ATT_OUT" | grep -qE "$FAILPAT"; then
FAIL=$((FAIL+1)); FAILED_CASES+=("record create attachment-url (typecast)")
echo "FAIL record create attachment-url (typecast)"
echo "$ATT_OUT" | grep -E "$FAILPAT" | head -2 | sed 's/^/ /'
elif echo "$ATT_OUT" | grep -q '"token"'; then
PASS=$((PASS+1)); [ "${SMOKE_VERBOSE:-0}" = 1 ] && echo "pass record create attachment-url (typecast)"
else
echo "warn record create attachment-url (typecast): no attachment token in response (download failed?)"
fi
ATT_OUT2=$($CLI record create --table-id "$TBL" --fields "{\"Title\":\"att2\",\"Files\":[{\"url\":\"$ATT_URL\",\"name\":\"readme.md\"}]}" --json 2>&1)
if echo "$ATT_OUT2" | grep -qE "$FAILPAT"; then
FAIL=$((FAIL+1)); FAILED_CASES+=("record create attachment-url (object)")
echo "FAIL record create attachment-url (object)"
echo "$ATT_OUT2" | grep -E "$FAILPAT" | head -2 | sed 's/^/ /'
elif echo "$ATT_OUT2" | grep -q '"token"'; then
PASS=$((PASS+1)); [ "${SMOKE_VERBOSE:-0}" = 1 ] && echo "pass record create attachment-url (object)"
else
echo "warn record create attachment-url (object): no attachment token in response (download failed?)"
fi
smoke "record create --records" $CLI record create --table-id "$TBL" --records '[{"fields":{"Title":"r2"}},{"fields":{"Title":"r3"}}]'
REC=$($CLI record list --table-id "$TBL" --json | j "d['records'][0]['id']")
STATUS_FLD=$($CLI field list "$TBL" --json | j "[f['id'] for f in d if f['name']=='Status'][0]")
smoke "record list" $CLI record list --table-id "$TBL"
smoke "record get" $CLI record get --table-id "$TBL"
smoke "record get --filter" $CLI record get --table-id "$TBL" --filter "{\"conjunction\":\"and\",\"filterSet\":[{\"fieldId\":\"$STATUS_FLD\",\"operator\":\"is\",\"value\":\"todo\"}]}"
smoke "record get --take/--skip" $CLI record get --table-id "$TBL" --take 2 --skip 1
smoke "record get --page-size" $CLI record get --table-id "$TBL" --page-size 1 --page 2
smoke "record get --search" $CLI record get --table-id "$TBL" --search r1
smoke "record get --projection" $CLI record get --table-id "$TBL" --projection '["Title"]'
smoke "record get-one" $CLI record get-one "$REC" --table-id "$TBL"
smoke "record get --record-id" $CLI record get --record-id "$REC" --table-id "$TBL"
smoke "record update" $CLI record update --table-id "$TBL" --records "[{\"id\":\"$REC\",\"fields\":{\"Num\":42}}]"
# --field-key-type / --order are body keys of create/updateRecordsRoSchema
VIEW=$($CLI view list "$TBL" --json | j "d[0]['id']")
TITLE_FLD=$($CLI field list "$TBL" --json | j "[f['id'] for f in d if f['name']=='Title'][0]")
REC2=$($CLI record list --table-id "$TBL" --json | j "d['records'][1]['id']")
smoke "record create --field-key-type id" $CLI record create --table-id "$TBL" --field-key-type id --fields "{\"$TITLE_FLD\":\"by-fld-id\"}"
smoke "record create --order" $CLI record create --table-id "$TBL" --order "{\"viewId\":\"$VIEW\",\"anchorId\":\"$REC2\",\"position\":\"before\"}" --fields '{"Title":"ordered-create"}'
smoke "record update --order" $CLI record update --table-id "$TBL" --order "{\"viewId\":\"$VIEW\",\"anchorId\":\"$REC2\",\"position\":\"after\"}" --records "[{\"id\":\"$REC\",\"fields\":{\"Num\":7}}]"
smoke "record visible-choices" $CLI field visible-choices "$TBL" "$REC" Status
smoke "record delete (positional)" $CLI record delete "$REC" --table-id "$TBL"
smoke "record delete (--csv)" $CLI record delete --record-ids "$($CLI record list --table-id "$TBL" --json | j "','.join(r['id'] for r in d['records'])")" --table-id "$TBL"
# ---------- view ----------
smoke "view list (positional)" $CLI view list "$TBL"
smoke "view list --table-id" $CLI view list --table-id "$TBL"
VIW=$($CLI view create --table-id "$TBL" --name smoke_v --json | j "d['id']")
smoke "view create" $CLI view get "$VIW" --table-id "$TBL"
smoke "view get" $CLI view get "$VIW" --table-id "$TBL"
smoke "view update --name" $CLI view update "$VIW" --table-id "$TBL" --name smoke_v2
smoke "view update --filter" $CLI view update "$VIW" --table-id "$TBL" --filter "{\"conjunction\":\"and\",\"filterSet\":[{\"fieldId\":\"$STATUS_FLD\",\"operator\":\"is\",\"value\":\"todo\"}]}"
smoke "view update --sort" $CLI view update "$VIW" --table-id "$TBL" --sort "[{\"fieldId\":\"$STATUS_FLD\",\"order\":\"desc\"}]"
smoke "view update --group" $CLI view update "$VIW" --table-id "$TBL" --group "[{\"fieldId\":\"$STATUS_FLD\",\"order\":\"asc\"}]"
# ---------- preset ----------
smoke "preset list" $CLI preset list "$TBL"
PID=$($CLI preset create --table-id "$TBL" --view-id "$VIW" --name smoke_p --json | j "d['id']") || PID=""
if [ -n "${PID:-}" ]; then
smoke "preset create" $CLI preset list "$TBL"
smoke "preset update" $CLI preset update "$TBL" "$PID" --name smoke_p2
smoke "preset delete" $CLI preset delete "$TBL" "$PID"
fi
# ---------- workflow / trigger / action / runs / fire ----------
WF=$($CLI workflow create --name smoke_wf --json | j "d['id']")
smoke "workflow create" $CLI workflow get "$WF"
smoke "workflow list" $CLI workflow list
smoke "workflow get" $CLI workflow get "$WF"
smoke "workflow update" $CLI workflow update "$WF" --name smoke_wf2 --description d
smoke "trigger webhook" $CLI trigger set "$WF" --type webhook
smoke "trigger scheduledTime" $CLI trigger set "$WF" --type scheduledTime --schedule '{"timing":{"type":"hours","interval":1}}'
smoke "trigger recordCreated" $CLI trigger set "$WF" --type recordCreated --table-id "$TBL"
smoke "trigger recordUpdated" $CLI trigger set "$WF" --type recordUpdated --table-id "$TBL" --field-ids '[]'
smoke "trigger recordCondition" $CLI trigger set "$WF" --type recordCondition --table-id "$TBL" --conditions "{\"conjunction\":\"and\",\"filterSet\":[{\"fieldId\":\"$STATUS_FLD\",\"operator\":\"is\",\"value\":\"done\"}]}"
smoke "trigger formSubmitted" $CLI trigger set "$WF" --type formSubmitted --table-id "$TBL"
$CLI field create --table-id "$TBL" --name Go --type button --options "{\"label\":\"Go\",\"style\":\"blue\",\"workflow\":{\"id\":\"$WF\",\"name\":\"smoke_wf2\"}}" >/dev/null
BTN=$($CLI field list "$TBL" --json | j "[f['id'] for f in d if f['name']=='Go'][0]")
smoke "trigger buttonClick" $CLI trigger set "$WF" --type buttonClick --table-id "$TBL" --field-ids "[\"$BTN\"]"
$CLI record create --table-id "$TBL" --fields '{"Title":"fire-test"}' >/dev/null
FREC=$($CLI record list --table-id "$TBL" --json | j "[r['id'] for r in d['records'] if r['fields'].get('Title')=='fire-test'][0]")
$CLI action add "$WF" --type script --code 'return {ok:true}' --json > /tmp/smoke_wf.json
AID=$(j "[n['id'] for n in d['nodes'] if n['category']=='script'][-1]" < /tmp/smoke_wf.json)
smoke "action add script" $CLI workflow get "$WF"
smoke "action add sendNotification" $CLI action add "$WF" --type sendNotification --title smoke --message hello
smoke "action update" $CLI action update "$WF" "$AID" --code 'return {ok:2}'
smoke "action delete" $CLI action delete "$WF" "$AID"
smoke "workflow activate" $CLI workflow activate "$WF"
smoke "fire button-click" $CLI fire button-click "$TBL" "$FREC" "$BTN"
sleep 4
smoke "runs list" $CLI runs list "$WF"
RUN=$($CLI runs list "$WF" --json | j "d[0]['id']" 2>/dev/null) || RUN=""
[ -n "${RUN:-}" ] && smoke "runs get" $CLI runs get "$WF" "$RUN"
[ -n "${RUN:-}" ] && smoke "runs rerun" $CLI runs rerun "$WF" "$RUN"
smoke "trigger set back to webhook" $CLI trigger set "$WF" --type webhook
smoke "fire webhook" $CLI fire webhook "$BASE" "$WF" --body '{"x":1}'
smoke "workflow test" $CLI workflow test "$WF"
# ---------- secret (before workflow delete — secrets belong to the workflow) ----------
SID=$($CLI secret set "$WF" --name SMOKE_KEY --value v1 --description smoke --json | j "d['id']") || SID=""
smoke "secret set" $CLI secret list "$WF"
smoke "secret list" $CLI secret list "$WF"
smoke "secret update" $CLI secret update "$WF" --name SMOKE_KEY --value v2
[ -n "${SID:-}" ] && smoke "secret delete" $CLI secret delete "$WF" "$SID"
smoke "workflow deactivate" $CLI workflow deactivate "$WF"
smoke "workflow delete" $CLI workflow delete "$WF"
# ---------- subscription ----------
SUB=$($CLI subscription create --name smoke_sub --table-id "$TBL" --send-mode instant --json 2>/dev/null | j "d.get('id','')" 2>/dev/null) || SUB=""
if [ -n "${SUB:-}" ]; then
smoke "subscription create" $CLI subscription get "$SUB"
smoke "subscription get" $CLI subscription get "$SUB"
smoke "subscription list" $CLI subscription list
smoke "subscription delete" $CLI subscription delete "$SUB"
else
echo "warn subscription create returned no id — check manually"
fi
# ---------- plugin (reads only; register/install need a real bundle) ----------
smoke "plugin list" $CLI plugin list
smoke "plugin installs" $CLI plugin installs
smoke "plugin csp list" $CLI plugin csp list
# ---------- auth token routes (via low-priv account session; PAT cannot mint PATs) ----------
# Provide SMOKE_LOWPRIV_EMAIL / SMOKE_LOWPRIV_PASSWORD (any account you may sign in).
if [ -n "${SMOKE_LOWPRIV_EMAIL:-}" ] && [ -n "${SMOKE_LOWPRIV_PASSWORD:-}" ]; then
curl -s -c /tmp/cli-smoke-cookie.txt "$EP/api/auth/signin" -X POST -H 'Content-Type: application/json' \
-d "{\"email\":\"$SMOKE_LOWPRIV_EMAIL\",\"password\":\"$SMOKE_LOWPRIV_PASSWORD\"}" >/dev/null
PATID=$(curl -s -b /tmp/cli-smoke-cookie.txt -X POST "$EP/api/access-token" -H 'Content-Type: application/json' \
-d '{"name":"cli-smoke","scopes":["table|read"],"expiredTime":"2027-01-01T00:00:00.000Z"}' \
| python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))")
if [ -n "$PATID" ]; then
CODE=$(curl -s -o /dev/null -w '%{http_code}' -b /tmp/cli-smoke-cookie.txt -X DELETE "$EP/api/access-token/$PATID")
if [ "$CODE" = "200" ]; then PASS=$((PASS+1)); echo "pass auth token create+delete (REST, session)"; else FAIL=$((FAIL+1)); echo "FAIL auth token delete (HTTP $CODE)"; fi
else
echo "warn auth token create via session failed — check creds"
fi
else
echo "warn auth-token routes not covered (set SMOKE_LOWPRIV_EMAIL/PASSWORD to enable)"
fi
# ---------- plugin chain (opt-in: SMOKE_PLUGINS=1; uploads to the registry, cleaned after) ----------
if [ "${SMOKE_PLUGINS:-0}" = "1" ] && [ -f /root/pi-skill-lab/plugin-workspace/fixture-deploy-W/plugin.json ]; then
FIXT=/root/pi-skill-lab/plugin-workspace/fixture-deploy-W
PID2=$(python3 -c "import json;print(json.load(open('$FIXT/plugin.json'))['id'])")
UP=$(curl -s -b "auth_session=unused" -H "Authorization: Bearer $TOK" \
-F "js=@$FIXT/dist/main.js" -F "manifest=@$FIXT/plugin.json" \
"$EP/api/plugin/$PID2/upload-bundle" | python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
if [ -n "$UP" ]; then
smoke "plugin upload-bundle" $CLI plugin list
smoke "plugin-config enable" curl -sf -X PUT -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"enabled":true,"tableIds":[]}' "$EP/api/base/$BASE/plugin-config/$PID2" -o /dev/null
smoke "plugin install" $CLI plugin install --plugin "$PID2" --name cli-smoke --position base
IID=$($CLI plugin installs --json 2>/dev/null | python3 -c "
import sys,json
for i in (json.load(sys.stdin) or []):
if i.get('pluginId')=='$PID2': print(i.get('id') or i.get('installId','')); break" 2>/dev/null)
[ -n "$IID" ] && smoke "plugin uninstall" $CLI plugin uninstall --install "$IID"
CODE=$(curl -s -o /dev/null -w '%{http_code}' -X DELETE -H "Authorization: Bearer $TOK" "$EP/api/plugin/$PID2")
if [ "$CODE" = "200" ]; then PASS=$((PASS+1)); echo "pass plugin registry cleanup"; else FAIL=$((FAIL+1)); echo "FAIL plugin delete (HTTP $CODE)"; fi
else
echo "warn plugin upload failed — PAT lacks register rights: POST /api/plugin and DELETE /api/plugin/:id are admin-only (403); upload-bundle alone needs the plugin_developer whitelist. Provide an admin session to cover the full chain."
fi
fi
# ---------- table delete (destructive, scratch only) ----------
smoke "table delete" $CLI table delete "$TBL"
echo
echo "==== smoke: $PASS passed, $FAIL failed ==="
[ $FAIL -gt 0 ] && { printf 'failed: %s\n' "${FAILED_CASES[@]}"; exit 1; }
exit 0
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Sync the repo's skills/ to every installed agent skill dir on this host.
# Single source of truth: this repo's skills/ directory.
set -eu
REPO="$(cd "$(dirname "$0")/.." && pwd)"
for d in ~/.zcode/skills ~/.agents/skills ~/.pi/agent/skills; do
[ -d "$d" ] || continue
for s in inkreach-teable inkreach-teable-plugin; do
rm -rf "$d/$s"
cp -r "$REPO/skills/$s" "$d/"
done
echo "synced -> $d"
done
+159
View File
@@ -0,0 +1,159 @@
---
name: inkreach-teable-plugin
description: >-
Develop dynamic React component plugins for inkreach-teable — IIFE bundles
loaded at runtime via window.__TEABLE_HOST__, no iframe sandbox, full host
API access. Scaffold with plugin-cli, build with webpack, deploy via
upload-bundle API, register via definePlugin + PluginContext. Positions:
contextMenu, panel, view, dashboard, route. Use this whenever the user
mentions plugins, 插件, batch-edit, context-menu actions, panel widgets,
dashboard charts, custom views, plugin.json, definePlugin, bundle plugin,
plugin-cli, plugin-sdk, plugin-host, or window.__TEABLE_HOST__. Even if
the user just says "add a right-click action" or "make a side panel" or
"I need a dashboard widget" — or wants the native form embedded or
extended inside a plugin (原生表单, 表单注入, 表单虚拟字段, custom form
layout, NativeForm, NativeFormScope, form panel) — load this skill.
Reference example at `plugins/batch-edit-pro/` (on the deployment host: `/root/deployments/inkreach-teable/plugins/batch-edit-pro/`).
---
# inkreach-teable dynamic plugin development
This skill develops React component plugins that load at runtime without
rebuilding the host Next.js app. Plugins are IIFE bundles — they ship as plain
JS that executes in the host page and accesses host APIs through
`window.__TEABLE_HOST__`.
> **Contract**: A plugin = a webpack (or vite) IIFE bundle (`dist/main.js` +
> optional lazy chunks `*.chunk.js`, source maps `*.js.map`, `styles.css`) +
> a `plugin.json` manifest, uploaded to the host via
> `POST /api/plugin/:id/upload-bundle` (or `teable-plugin deploy`, which
> handles all assets). It runs in the same React tree as the host and has
> full access to the host's SDK, UI library, and OpenAPI helpers.
>
> **Dev loop**: `teable-plugin dev` (or `pnpm dev` in scaffolded projects)
> watch-builds and auto-uploads with a `devMode` manifest; the open host page
> remounts the plugin within ~4s without a page refresh. See
> `dynamic-plugins/dev-workflow.md`.
>
> **Full-page apps**: install at position `route` and the host serves the
> plugin on `/base/<baseId>/p/<pluginId>` (whole viewport, own router OK —
> HashRouter recommended).
>
> **Critical constraint**: no RUNTIME imports from `@teable/sdk`, `@teable/ui-lib`,
> `@teable/openapi` — a value import via monorepo aliases inlines the whole
> package source (via window-global externals it compiles to `undefined`).
> `import type { X } from '@teable/...'` IS fine (erased at compile time).
> `react`/`react-dom` may be imported when your webpack `externals` maps them
> to `window.__TEABLE_HOST__.React/ReactDOM` (see the plugin-showcase webpack
> config; `react/jsx-runtime` is intentionally bundled — element factories
> only, hooks stay on the host singleton). For everything else, read from the
> host global: `const { useFields } = window.__TEABLE_HOST__.SDK`. See
> `dynamic-plugins/troubleshooting.md` Pitfall 1 for details and symptoms.
## 0. Two efficiency rules (read first)
- **Trust these docs.** The `dynamic-plugins/` documents are the verified contract
for this deployment. Answer plugin capability/behavior questions directly from
them — do NOT re-derive behavior by spelunking `nestjs-backend/src` or the host
source tree; that burns minutes and adds nothing. Source diving is only for
debugging a concrete discrepancy the docs can't explain.
- **Don't upload demo plugins to verify knowledge.** For how-to/consultation
questions, a written answer (with code) is the deliverable. Only upload a bundle
when the user actually asked to deploy — test uploads belong in a throwaway
sandbox base, never the production plugin registry.
## 1. When to use this skill
- User says "写个插件 / 做个插件 / make a plugin" — yes.
- User wants a context-menu action (batch edit / process selected records) — yes.
- User wants a panel gadget (real-time stats / summary) — yes.
- User wants a custom view (kanban, gallery, timeline) — yes.
- User wants a dashboard chart — yes.
- User wants to embed / extend the NATIVE FORM in a plugin (原生表单, form
injection, virtual form fields, custom multi-step form with native field
editors, `host.NativeForm`, `NativeFormScope`) — yes, see `sdk-api.md`.
- User mentions `definePlugin`, `plugin-cli`, `plugin-sdk`, `window.__TEABLE_HOST__`, bundle plugin — yes.
- User wants to manage tables/automations (not plugins) — use `inkreach-teable` skill instead.
## 2. Module map (read in order when developing)
| Stage | File | Read it when... |
|---|---|---|
| 1. Understand architecture | `dynamic-plugins/overview.md` | always first — how `window.__TEABLE_HOST__`, PluginLoader, PluginSlot work |
| 2. Dev workflow | `dynamic-plugins/dev-workflow.md` | when building a plugin — the step-by-step recipe from scaffold to deploy |
| 3. SDK API | `dynamic-plugins/sdk-api.md` | when writing plugin logic — definePlugin, PluginContext, events, storage, useSelection, native form embedding (`NativeForm` Tier-1 injection / `NativeFormScope`+`useNativeForm` Tier-2 kit), NativeGrid |
| 3b. Native form quick answer | `dynamic-plugins/native-form.md` | **when the user ASKS whether/how to embed native field editors, custom form layout, or multi-step forms — read this FIRST and answer directly (consultative questions need no scaffold/build)** |
| 4. Deployment | `dynamic-plugins/deployment.md` | when uploading + enabling + authority matrix permissions |
| 5. Troubleshooting | `dynamic-plugins/troubleshooting.md` | before writing logic — common pitfalls (host globals, developer whitelist, AM, empty states, chunk-name flattening, terser banners) |
| Ref: plugin-showcase | `plugins/plugin-showcase/` | the v2 example — lazy chunks + source maps + Tailwind + HashRouter route app |
| Ref: batch-edit-pro | `plugins/batch-edit-pro/` | the classic example — single-file contextMenu plugin, full React + SDK |
## 3. Quick decision: which position?
| User wants | Position | `definePlugin` registration |
|---|---|---|
| Right-click action on records | **ContextMenu** | `ctx.addContextMenuAction(id, component)` |
| Side gadget while viewing a table | **Panel** | `ctx.addPanel(id, component)` |
| A new view type | **View** | `ctx.addView(id, component)` |
| Card on the space dashboard | **Dashboard** | `ctx.addDashboardWidget(id, component)` |
| A new page/route | **Route** | `ctx.addRoute(path, component)` |
## 4. Auth & test credentials
- Endpoint: `http://localhost:3003` (backend; `:3000` is taken by an unrelated
docker container on this host — see AGENTS.md), frontend `http://localhost:3002`.
- Plugin upload requires a `plugin_developer` row for the uploading user
(`INSERT INTO plugin_developer (id, user_id, email, created_time, created_by)
VALUES ('plgd<name>', '<userId>', '<email>', now(), '<userId>')`).
- **Sandbox base for any hands-on test**: create a throwaway base
(`POST /api/base` with `{"spaceId":"<spaceId>","name":"Sandbox(可删)"}`),
do all table/install testing inside it, and delete it (`DELETE /api/base/:id`)
when done. Never hardcode base/table IDs here — they rot; resolve them at
runtime (`inkreach-teable base list`) instead.
- Auth: PAT (`Authorization: Bearer <PAT>`; upload-bundle supports it) or
session cookie `auth_session`. Note: some admin-gated routes (e.g.
`DELETE /api/plugin/:id`) pass with an admin session but 403 with a plain PAT
even for the same user — fall back to the session for those.
## 5. Start here
For any plugin request, read `dynamic-plugins/overview.md` then
`dynamic-plugins/dev-workflow.md`. Reference `plugins/batch-edit-pro/` for a
complete working example.
## 6. Plugin backend services — MUST use the same-origin proxy (2026-08-21)
The instance is publicly served at `https://table.inkreach.cc` (frp + cloud Caddy);
browsers on the public internet CANNOT reach LAN addresses like
`http://192.168.124.92:8001`. Plugin bundles that need their own backend service
MUST call it through the session-gated dynamic proxy instead:
- **URL contract**: `fetch('/api/plugin-proxy/<service>/<path...>')` — same-origin,
works from LAN and public internet alike. NEVER hardcode LAN IPs / `localhost`
in a bundle.
- **Registry**: one line in `PLUGIN_API_SERVICES` (JSON, in
`apps/nextjs-app/.env` AND `.env.development`), then restart
`teable-backend.service`. No rebuild, no code change.
Example: `PLUGIN_API_SERVICES={"track":"http://127.0.0.1:8001"}`
- **Implementation**: NestJS controller
`apps/nestjs-backend/src/features/plugin/plugin-proxy.controller.ts`
(global AuthGuard gates login; upstream whitelist = registry; session cookies
are never forwarded upstream). Do NOT move this to a Next.js pages-router API
route — the `/api/:path*` rewrite in `next.config.js` outranks catch-all file
routes, and files outside `pages/api/` get compiled as SSR pages (observed on
Next 16.1.6/turbopack).
- **CSP**: same-origin fetches need nothing; truly external origins must be
declared in the plugin manifest `apiOrigins` (appended to connect-src by
`plugin-runtime.controller.ts`).
- Re-uploading a plugin zip OVERWRITES the bundle — re-check for hardcoded LAN
URLs afterwards (`grep -o 'http://192.168[^\"']*' <bundle>/main.js`).
Live example: `apps/nestjs-backend/.assets/plugins/bundle/track-panel/`
(patched from LAN URL to `/api/plugin-proxy/track`; backups kept as
`main.js.bak-*`).
- **Root fix (2026-08-22 recurrence)**: a developer re-uploaded track-panel at
02:23 with the LAN URL back in the source-built bundle, breaking public
search with a CORS error within hours of the last patch. Patching
`main.js` on disk only fixes the CURRENT artifact — the plugin SOURCE must
use `/api/plugin-proxy/track` before building/uploading, otherwise every
re-upload reintroduces the bug. The backend serves bundle files from disk
per-request, so a disk patch takes effect without a backend restart.
@@ -0,0 +1,235 @@
# Plugin deployment
Uploading a bundle plugin, enabling it, and managing permissions.
## Upload — `POST /api/plugin/:pluginId/upload-bundle`
Upload the built bundle to the host. Uses `multipart/form-data`.
The `pluginId` in the URL must match the `id` in `plugin.json`.
Preferred (handles entry + chunks + maps + css automatically):
```bash
TEABLE_ENDPOINT=http://localhost:3003 TEABLE_PAT=<pat> \
node /root/deployments/inkreach-teable/packages/plugin-cli/dist/index.js deploy -d .
```
Raw curl equivalent (legacy single-file form still works):
```bash
PLUGIN_ID=$(python3 -c "import json; print(json.load(open('plugin.json'))['id'])")
curl -s -H "Authorization: Bearer $TEABLE_PAT" \
-F "js=@dist/main.js" \
-F "css=@dist/styles.css" \
-F "manifest=@plugin.json" \
"http://localhost:3003/api/plugin/$PLUGIN_ID/upload-bundle"
```
**Auth note**: Supports both session cookies (`auth_session`) and Personal Access
Tokens (`Authorization: Bearer <PAT>`). Runtime asset GETs
(`/api/plugin/:id/runtime/*`) are session-only (PAT gets 403 by design) —
verify those with a signed cookie, not a PAT.
**Fields** (v2 multi-asset, since 2026-10):
| Field | Required | Max count | Max size | Description |
|---|---|---|---|---|
| `js` | Yes | 20 | 5 MB each | Entry + lazy chunks. Entry = the file whose name matches `manifest.entry` (a lone differently-named js file still works — legacy fallback) |
| `css` | No | 5 | 1 MB each | Stylesheets, concatenated under the `manifest.style` name |
| `map` | No | 20 | 5 MB each | Source maps, served later as `application/json` |
| `manifest` | Yes | 1 | 10 KB | `plugin.json` manifest |
- **Total cap 15 MB** across all assets.
- Chunk filenames must be **FLAT** (`x-<hash>.chunk.js`) — the multipart
transport strips directory parts from filenames.
- The bundle dir on disk (`<backend-cwd>/.assets/plugins/bundle/<pluginId>/`)
is **wiped on every upload** — stale chunks are removed, and hand-patched
files on disk are discarded.
**Returns** `{ pluginId, hash, size, assets }` — `assets` is the stored file
count (1 for legacy single-file uploads).
**Registering a NEW plugin id**: `POST /api/plugin {"id": "<kebab-id>", ...}`
— PAT-accessible (`@TokenAccess()` since 2026-10) and **custom ids require
the `plugin_developer` whitelist**. Without an explicit `id` the server
mints a `plgXXX` id, which CANNOT receive bundles (manifest id must equal
the DB id). Deleting a plugin (`DELETE /api/plugin/:id`) remains admin-only.
**Security** (7-layer validation):
1. Authentication — valid session cookie or PAT required (401 if missing)
2. Developer whitelist — `plugin_developer` table lookup (403 if not whitelisted)
3. Rate limit — 10 uploads/min per user (429 if exceeded)
4. Size limits — JS/map ≤ 5 MB each, CSS ≤ 1 MB each, manifest ≤ 10 KB,
15 MB total
5. Manifest validation — required fields, `id` regex `[a-z0-9-]+`,
`minAppVersion` ≤ host version
6. Content validation — the ENTRY must be IIFE format (prefix `!function` /
`(function` / `(() =>` / `var <name>=<iife>`); chunks get the
forbidden-pattern check only (they are not IIFEs); JS rejects
`<script>`/`document.write`/`javascript:`; CSS strips `expression()`/
`@import`/`javascript:` URIs; maps are name/size checked only (they embed
original source text — pattern checks would false-positive)
7. Integrity — entry SHA-256 stored in DB for audit + PluginAuditLog row
(uploader/IP/UA); note the loader does NOT re-verify the hash at load time
## Enable — `PUT /api/base/:baseId/plugin-config/:pluginId`
The plugin must be enabled before it can be installed to any position.
```bash
curl -s -b "auth_session=$SESSION" -X PUT \
"http://localhost:3003/api/base/$BASE_ID/plugin-config/$PLUGIN_ID" \
-H 'Content-Type: application/json' \
-d '{"enabled":true,"tableIds":[]}'
```
- `tableIds: []` — enabled base-wide (all tables)
- `tableIds: ["tblX", "tblY"]` — enabled only for specific tables
## Install by position
### ContextMenu
```bash
curl -s -b "auth_session=$SESSION" -X POST \
"http://localhost:3003/api/table/$TABLE_ID/plugin-context-menu/install" \
-H 'Content-Type: application/json' \
-d '{"pluginId":"$PLUGIN_ID","name":"My Plugin"}'
```
Body: `pluginId` (required), `name` (optional display name).
### Panel
Panel install requires an existing panel. First create one, then install to it:
```bash
# Create a panel (if none exists)
PANEL_ID=$(curl -s -b "auth_session=$SESSION" -X POST \
"http://localhost:3003/api/table/$TABLE_ID/plugin-panel" \
-H 'Content-Type: application/json' \
-d '{"name":"My Panel"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
# Install plugin to the panel
curl -s -b "auth_session=$SESSION" -X POST \
"http://localhost:3003/api/table/$TABLE_ID/plugin-panel/$PANEL_ID/install" \
-H 'Content-Type: application/json' \
-d '{"pluginId":"$PLUGIN_ID","name":"My Plugin"}'
```
The `plg` prefixed ID is the panel's UUID, not a plugin ID. Create a panel first
if you don't already have one.
### Dashboard
```bash
curl -s -b "auth_session=$SESSION" -X POST \
"http://localhost:3003/api/base/$BASE_ID/dashboard/$DASHBOARD_ID/plugin" \
-H 'Content-Type: application/json' \
-d '{"pluginId":"$PLUGIN_ID","name":"My Plugin"}'
```
### View
```bash
curl -s -b "auth_session=$SESSION" -X POST \
"http://localhost:3003/api/table/$TABLE_ID/view/plugin" \
-H 'Content-Type: application/json' \
-d '{"pluginId":"$PLUGIN_ID","name":"My Plugin"}'
```
Returns `{ pluginId, pluginInstallId, name, viewId }`. Creates a new view
of type `plugin` in the table's view list. The plugin's `definePlugin.onLoad`
must call `ctx.addView(id, component)` to render the component as a view.
Authority Matrix entries are auto-created for the new install (default
denied for non-owner roles).
### Route (full-page app, since 2026-10)
The host renders a dedicated page for the plugin at
`/base/<baseId>/p/<pluginId>` — the whole main-area viewport inside the base
layout. The plugin registers via `ctx.addRoute(id, component)` and typically
brings its own router (HashRouter recommended, so Next.js routing is
untouched).
```bash
curl -s -H "Authorization: Bearer $TEABLE_PAT" -X POST \
"http://localhost:3003/api/base/$BASE_ID/plugin-installs" \
-H 'Content-Type: application/json' \
-d '{"pluginId":"$PLUGIN_ID","name":"My App","position":"route"}'
```
Notes (verified 2026-10 on plugin-showcase):
- `manifest.positions` must include `"route"`.
- The page itself does not gate on plugin-config enablement — the URL is the
access point. Follow the standard enable+install flow anyway so the plugin
shows up in management UIs and future permission layers behave uniformly.
- Host SDK hooks (e.g. `host.SDK.useBaseId()`) work on the route page because
it renders inside BaseLayout's provider stack.
## Authority Matrix (plugin permissions)
Plugins integrate with the 4-layer permission system. Three control layers:
| Layer | What it controls | Mechanism |
|---|---|---|
| **Plugin visible** | User sees plugin in context menu / panel / dashboard | `AuthorityMatrixRolePluginInstall` per-role per-install |
| **Plugin executable** | User can click and run the plugin | Checked at `PluginSlot` render |
| **Data operations** | Plugin's CRUD respects user's permissions | **Already protected** — plugins call `@teable/openapi` which passes through existing AM enforcement |
Default behavior:
- New installs default to disabled for non-owner roles
- Base owner always has full access
- Admin enables per role through the RoleEditor UI
## Developer whitelist
Only users in the `plugin_developer` table can upload bundles. To add a developer:
```sql
INSERT INTO plugin_developer (id, user_id, email, created_by)
VALUES (gen_random_uuid(), '<userId>', '<email>', '<adminUserId>');
```
## Getting a session cookie
Prefer a PAT (upload-bundle accepts `Authorization: Bearer <PAT>`). For a
session cookie, sign in with any real account created for the purpose
(`POST /api/auth/signup`), then add its `plugin_developer` row before uploading:
```bash
curl -s -c /tmp/cookie.txt http://localhost:3003/api/auth/signin \
-X POST -H 'Content-Type: application/json' \
-d '{"email":"<real-account>","password":"<password>"}'
SESSION=$(grep auth_session /tmp/cookie.txt | awk '{print $NF}')
```
## Full deploy script (copy-paste)
```bash
#!/bin/bash
set -e
DIR="${1:-.}"
UPLOAD_URL="${UPLOAD_URL:-http://localhost:3003}"
BASE_ID="${BASE_ID:?set to a throwaway sandbox base, never a production base}"
# Read plugin ID from manifest
PLUGIN_ID=$(python3 -c "import json; print(json.load(open('$DIR/plugin.json'))['id'])")
# Upload: teable-plugin deploy handles entry + chunks + maps + css.
# Raw-curl equivalent for a single-file bundle:
# curl -s -H "Authorization: Bearer $TEABLE_PAT" \
# -F "js=@$DIR/dist/main.js" -F "css=@$DIR/dist/styles.css" \
# -F "manifest=@$DIR/plugin.json" \
# "$UPLOAD_URL/api/plugin/$PLUGIN_ID/upload-bundle"
TEABLE_ENDPOINT="$UPLOAD_URL" TEABLE_PAT="${TEABLE_PAT:?set PAT env}" \
node /root/deployments/inkreach-teable/packages/plugin-cli/dist/index.js deploy -d "$DIR"
echo "Uploaded: $PLUGIN_ID"
# Enable
curl -s -H "Authorization: Bearer $TEABLE_PAT" -X PUT \
"$UPLOAD_URL/api/base/$BASE_ID/plugin-config/$PLUGIN_ID" \
-H 'Content-Type: application/json' \
-d '{"enabled":true,"tableIds":[]}' > /dev/null
echo "Enabled: $PLUGIN_ID"
```
@@ -0,0 +1,316 @@
# Plugin development workflow
The standard recipe for creating, building, and deploying a dynamic React
component plugin. Each plugin is a standalone webpack (or vite) project that
outputs an IIFE bundle. Since 2026-10 the pipeline is multi-asset: lazy
chunks, source maps, and multiple CSS files upload alongside the entry.
## Output contract
Every plugin task ends with:
1. A built IIFE bundle (`dist/main.js` + optional chunks/`*.chunk.js` +
`*.js.map` source maps + optional `styles.css`)
2. A `plugin.json` manifest
3. Uploaded to the host via `POST /api/plugin/:id/upload-bundle`
(or `teable-plugin deploy` which handles all assets)
4. Enabled via `PUT /api/base/:baseId/plugin-config/:pluginId`
5. Installed to the target position (the install API varies by position)
## Dev loop (hot reload — preferred while iterating)
```bash
cd plugins/my-plugin # or wherever the project lives
TEABLE_ENDPOINT=http://localhost:3003 TEABLE_PAT=<pat> pnpm dev
# or: node ../../packages/plugin-cli/dist/index.js dev -d .
```
This runs `webpack --watch` (production mode — dev-mode output carries
`/******/` banners that fail the IIFE validator), auto-uploads on every
change with a `devMode: true, buildId: <ts>` manifest, and the open host
page remounts the plugin within ~4s WITHOUT a page refresh. Production
deploys (no devMode in manifest) never poll. Verified 2026-10-13 on
plugin-showcase: edit → ~5s live update, window markers survive (SPA-level
remount, not a reload).
## Step 1 — Understand the request
Clarify what the user wants, then pick the position using `overview.md`.
State the choice: "This is a ContextMenu plugin because..."
## Step 2 — Scaffold the plugin project
The `plugin-cli` lives in the repo at `packages/plugin-cli/` (not yet published
to npm). Run it directly:
```bash
cd /tmp
node /root/deployments/inkreach-teable/packages/plugin-cli/dist/index.js create my-plugin
# or the vite template:
node /root/deployments/inkreach-teable/packages/plugin-cli/dist/index.js create my-plugin --template vite
cd my-plugin
pnpm install
```
The scaffold adapts automatically: inside the monorepo it wires
`@teable/plugin-sdk: workspace:*` + source aliases; standalone projects get a
loose-typed local host module (the npm package is not published). Vite
scaffolds also generate a `pnpm-workspace.yaml` with `allowBuilds: esbuild`
(pnpm 11 fails `pnpm install` without it).
This generates:
```
my-plugin/
├── src/
│ ├── host.ts # typed host accessor
│ └── index.tsx # entry: definePlugin via host.PluginSDK
├── plugin.json # manifest
├── webpack.config.js # or vite.config.ts
├── tsconfig.json
└── package.json # dev/build/deploy scripts
```
Reference implementations in the repo: `plugins/plugin-showcase/` (chunks +
sourcemap + Tailwind + HashRouter route app — the v2 showcase) and
`plugins/batch-edit-pro/` (classic single-file contextMenu plugin).
## Step 3 — Write the plugin code
Edit `src/index.tsx`. The shape every plugin follows:
```typescript
const React = window.__TEABLE_HOST__.React;
const host = window.__TEABLE_HOST__;
const { useFields, useTableId, useViewId } = host.SDK;
const { Button } = host.UILib;
const { getRecords } = host.OpenAPI;
const { definePlugin, useSelection, getSelectionRecords } = host.PluginSDK;
function MyComponent() {
const tableId = useTableId();
const fields = useFields();
// ... business logic
return React.createElement('div', null, 'Hello from plugin!');
}
definePlugin({
id: 'my-plugin',
name: 'My Plugin',
version: '1.0.0',
minAppVersion: '1.0.0',
onLoad(ctx) {
ctx.addContextMenuAction('myAction', MyComponent);
// or: ctx.addPanel('myPanel', MyComponent);
// or: ctx.addDashboardWidget('myWidget', MyComponent);
// or: ctx.addView('myView', MyComponent);
// or: ctx.addRoute('/my-page', MyComponent);
},
onUnload(ctx) {
// Optional cleanup; ctx auto-disposes registered resources
},
});
```
### Rules
- **Never hardcode** table IDs, view IDs, or tokens — use `useTableId()`,
`useViewId()`, and the event bus for contextual data.
- **Use `React.createElement`** or JSX (webpack handles transpilation). The
host's React instance is the single source of truth.
- **Loading / error / empty states** are required. Use `useState` to manage
async data loading and surface errors in the UI.
- For contextMenu plugins that use `getSelectionRecords`, handle the case
where nothing is selected (it returns an empty array).
- Plug in to events for reactivity: `ctx.on('selection.changed', ...)`,
`ctx.on('record.updated', ...)`.
### Editable `plugin.json`
```json
{
"id": "my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"minAppVersion": "1.0.0",
"entry": "main.js",
"style": "styles.css",
"positions": ["contextMenu"],
"permissions": ["data.read", "data.write"],
"author": "developer@example.com",
"icon": "Pencil"
}
```
- `id` must be unique, lowercase-kebab-case.
- `icon` is a lucide-react icon name (optional).
- `positions` must include the position you register in `onLoad`.
## Step 4 — Build
```bash
pnpm build
# = tsc --noEmit && webpack --mode production (scaffold script)
```
**Rebuilding without the scaffold — use this exact config** (agents
hand-writing webpack configs keep hitting `Error: Unsupported library type
iife` — there is NO `output.library` in a working plugin config; a default
single-entry production build is already IIFE, which is what the upload
validator requires):
```js
// webpack.config.js — known-good v2 (from plugins/plugin-showcase/)
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const pluginId = require('./plugin.json').id;
module.exports = {
mode: 'production',
entry: './src/index.tsx',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'main.js',
// FLAT chunk names: multipart transport strips directory parts, so
// nested chunk paths would 404 at runtime.
chunkFilename: '[name]-[contenthash].chunk.js',
// required so React.lazy/import() chunks resolve from the host runtime
publicPath: `/api/plugin/${pluginId}/runtime/`,
clean: true,
// terser's minified "(()=>{" fails the validator's "(() =>" check
environment: { arrowFunction: false },
},
devtool: 'source-map', // maps upload + serve as application/json
// NO output.library — never add library.type: 'iife' (webpack rejects it)
externals: {
'react': 'window.__TEABLE_HOST__.React',
'react-dom': 'window.__TEABLE_HOST__.ReactDOM',
},
resolve: { extensions: ['.tsx', '.ts', '.js', '.jsx'] },
module: {
rules: [
{ test: /\.tsx?$/, loader: 'ts-loader', options: { transpileOnly: true }, exclude: /node_modules/ },
{ test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader'] },
],
},
optimization: {
minimizer: [
// license-comment extraction prepends a "/*!..." banner that breaks
// the IIFE-prefix check — keep comments inline
new TerserPlugin({ extractComments: false }),
],
},
plugins: [new MiniCssExtractPlugin({ filename: 'styles.css' })],
};
```
`react/jsx-runtime` is intentionally NOT external: the jsx automatic
transform bundles it and that is safe (element factories only — hooks still
come from the host singleton).
Vite template notes: `build.lib formats: ['iife']` + rollup externals with
`globals: { react: 'window.__TEABLE_HOST__.React', ... }`; output starts
`(function(...)` or `var <name>=(function...` — both pass the validator.
IIFE cannot code-split (dynamic imports inline) — use the webpack template
if you need lazy chunks.
If you build outside the monorepo, drop the `resolve.alias` block (it exists
only so TS can type-check `import type` from `@teable/*` sources) and use
`import type` + `as unknown as` casts instead of runtime imports.
Outputs: `dist/main.js` (IIFE) + optional `dist/*.chunk.js` (lazy chunks) +
`dist/*.js.map` + `dist/styles.css`.
## Step 5 — Upload the bundle
Preferred (handles entry + chunks + maps + css automatically):
```bash
TEABLE_ENDPOINT=http://localhost:3003 TEABLE_PAT=<pat> \
node ../../packages/plugin-cli/dist/index.js deploy -d .
# or from the plugin dir: pnpm deploy
```
Raw curl equivalent (single-file legacy form still works):
```bash
PLUGIN_ID=$(python3 -c "import json; print(json.load(open('plugin.json'))['id'])")
curl -s -H "Authorization: Bearer $TEABLE_PAT" \
-F "js=@dist/main.js" \
-F "css=@dist/styles.css" \
-F "manifest=@plugin.json" \
"http://localhost:3003/api/plugin/$PLUGIN_ID/upload-bundle"
```
Multipart notes (v2, 2026-10):
- `js` field accepts up to 20 files (entry first or matched by manifest
entry filename), `css` up to 5, `map` up to 20.
- The plugin row must already exist (`POST /api/plugin {"id": "<kebab-id>"}`
— custom ids require the plugin_developer whitelist; the endpoint is
`@TokenAccess()` so a PAT works).
- Limits: 5MB per JS/map file, 1MB per CSS, 15MB total. The bundleDir is
WIPED before writing — stale chunks don't linger, but hand-patched files
on disk are discarded on the next upload.
- Auth supports session cookies (`auth_session`) and PATs
(`Authorization: Bearer`). Runtime GETs (`/api/plugin/:id/runtime/*`) are
session-only (PAT gets 403 by design).
## Step 6 — Enable (REQUIRED before install)
```bash
curl -s -b "auth_session=$SESSION" -X PUT \
"http://localhost:3003/api/base/bseAi6Hazmw7jPWZbfO/plugin-config/$PLUGIN_ID" \
-H 'Content-Type: application/json' \
-d '{"enabled":true,"tableIds":[]}'
```
`tableIds:[]` = base-wide; `tableIds:["tblX"]` = per-table. Use base-wide unless
the user wants to restrict to specific tables.
## Step 7 — Install to the position
The install API depends on position. See `deployment.md` for the exact call.
Example — contextMenu on the test table:
```bash
curl -s -b "auth_session=$SESSION" -X POST \
"http://localhost:3003/api/table/tbldtYd4ZhvkcyAaada/plugin-context-menu/install" \
-H 'Content-Type: application/json' \
-d "{\"pluginId\":\"$PLUGIN_ID\",\"name\":\"My Plugin\"}"
```
## Step 8 — Verify in the browser
Open the relevant page and confirm the plugin renders and works:
- ContextMenu: right-click a record in grid view → "Plugins" group
- Panel: bottom-right Plugins button in a table view
- View: view-type dropdown
- Dashboard: space dashboard page
- Route (full-page app): `/base/<baseId>/p/<pluginId>` — install at position
`route`, the page renders the plugin full-screen inside BaseLayout (bring
your own router; HashRouter recommended so Next.js routing is untouched).
Verified flow: enable via plugin-config → install via
`POST /api/base/:baseId/plugin-installs {"position":"route"}` → open the URL.
Use `browser-act` to verify or ask the user to check.
## Pre-deliver checklist
Before claiming done:
- [ ] `dist/main.js` built and is a valid IIFE bundle (`!function` / `(function` /
`(() =>` / `var x=(function` prefix — run `head -c 20 dist/main.js`)
- [ ] Chunks (if any) are FLAT names (`[name]-[contenthash].chunk.js`)
- [ ] `plugin.json` at project root with correct `id`, `entry`, `positions`
- [ ] No hardcoded URLs/table IDs/tokens — all via host SDK
- [ ] Loading, error, and empty states handled in the UI
- [ ] `definePlugin()` called with `onLoad(ctx)` that registers at least one component
- [ ] Upload returns 2xx (assets count > 1 when chunks/maps exist)
- [ ] Enable (plugin-config) called before install
- [ ] Install returns 2xx
- [ ] Plugin renders and works
- [ ] Dev-loop deploys redone as a production upload (no `devMode` in the
final manifest) before handover
If any box is unchecked, fix it.
@@ -0,0 +1,75 @@
# Native form embedding — quick answer
**问题:能不能在插件里做自定义布局的表单(多步表单/侧栏表单),每一步用系统
原生字段编辑器,不自己重写输入组件?**
**能。宿主暴露了完整的原生表单注入能力,共两档。咨询类问题到这里就有答案了,
不需要先去搭工程验证。**
| 需求 | 用哪档 | 核心 API(都在 `window.__TEABLE_HOST__`) |
|---|---|---|
| 整表单嵌入面板/视图,能裁剪字段、插自定义内容 | **Tier-1 整表注入** | `host.NativeForm` |
| 自己控制每行渲染、逐步表单、校验与暂存 | **Tier-2 逐字段套件** | `host.NativeFormScope` + `host.NativeFormField` + `host.useNativeForm()` |
## Tier-1:`<NativeForm>` 整表注入(最常用)
```typescript
const { NativeForm } = window.__TEABLE_HOST__;
function MyFormPanel() {
return (
<NativeForm
formId="viwIssueForm" // 一个 form 视图的 id
visibleFieldIds={['fldTitle', 'fldDesc']} // 裁剪+重排(仅会话级)
hiddenFieldIds={['fld_internalNote']} // 或只隐藏
slots={[ // 在任意位置插自定义内容
{ position: 'top', render: ({ values }) => <h3>提单</h3> },
]}
interceptSubmit={(fields) => fields} // 提交前改写载荷(原生提交与自动化照常)
/>
);
}
```
要点:原生提交、校验、`formSubmitted` 自动化全部照常;虚拟值放
`__virtual.<key>`,提交时自动剥离。
## Tier-2:多步表单(逐步切换字段、原生编辑器)
```typescript
const { NativeFormScope, NativeFormField, useNativeForm } = window.__TEABLE_HOST__;
const STEPS = [
['fldTitle', 'fldCustomer'],
['fldAmount', 'fldUrgency'],
['fldNotes'],
];
function Stepper({ formId }: { formId: string }) {
return (
<NativeFormScope formId={formId}> {/* 状态/L localStorage 暂存由 Scope 接管 */}
<StepBody formId={formId} />
</NativeFormScope>
);
}
function StepBody({ formId }: { formId: string }) {
const { values, setValue, errors, fields, step, setStep } = useNativeForm({ formId, steps: STEPS });
return (
<div>
{STEPS[step].map((fid) => (
<NativeFormField key={fid} fieldId={fid} /> {/* 100% 原生字段编辑器 */}
))}
<button onClick={() => setStep(step - 1)} disabled={step === 0}>上一步</button>
<button onClick={() => setStep(step + 1)} disabled={step === STEPS.length - 1}>下一步</button>
</div>
);
}
```
## 决策与边界
- 校验/错误提示:走原生(`errors` 由 hook 暴露);自定义校验放 `interceptSubmit`。
- `submitOverride` 会**绕过**原生提交 → `formSubmitted` 自动化不会触发(要触发就别用它)。
- computed/lookup 字段永远不可编辑(原生行为,注入也一样)。
- 详细 props 表与虚拟字段模式见 [sdk-api.md](sdk-api.md) 的 `NativeForm` / `NativeFormScope` 章节。
@@ -0,0 +1,195 @@
# Plugin system architecture
The host Next.js app (`:3002`) injects a global namespace `window.__TEABLE_HOST__`
during startup. Plugins are plain JS IIFE bundles that read this namespace at
runtime — no ES imports, no iframes, no penpal bridge.
## Why this system exists
The previous iframe/ZIP plugin system isolated plugins in sandboxed iframes,
limiting API access to a penpal postMessage bridge. Adding or changing a plugin
required rebuilding and re-deploying the host Next.js app. This system solves
both problems:
- **Full host API access**: Plugins run in the same React tree as the host,
sharing the same React singleton. They can use any SDK hook, UI component,
or OpenAPI helper directly.
- **No host rebuild**: Upload a new IIFE bundle via `upload-bundle` and it's
immediately available — the host loads it at runtime through PluginLoader.
- **Shared dependencies**: React, ReactDOM, and all `@teable/*` packages are
provided by the host, so plugin bundles are small (only contain the plugin's
own code).
The trade-off is that plugins are fully trusted (no sandboxing) — upload is
gated by a developer whitelist and bundles are validated for security.
## Runtime flow
```
1. Host app mounts
HostInitializer (<AppProvider>):
window.__TEABLE_HOST__ = {
React, ReactDOM,
SDK: { useFields, useRecords, useTableId, useViewId, ... },
UILib: { Button, Dialog, Select, ... },
OpenAPI: { getRecords, updateRecords, ... },
events: EventBus instance,
PluginSDK: { definePlugin, registerComponent, useSelection, getSelectionRecords },
// Native embeddable components (same React tree as the host app):
openFormModal, // modal wrapper around the native form
NativeGrid, // full native grid with providers
NativeForm, // full native form (Tier-1 injection props supported)
NativeFormScope, // Tier-2: state scope for custom form layouts
NativeFormField, // Tier-2: single native form row
useNativeForm, // Tier-2: headless hook { values, setValue, verify, submit, ... }
}
2. Plugin loads (PluginLoader component)
→ fetch /api/plugin/:id/runtime/plugin.json (manifest)
→ check minAppVersion compatibility
→ <link> inject CSS (if manifest.style exists)
→ <script src="/api/plugin/:id/runtime/main.js"> (IIFE executes)
→ if the bundle code-splits, lazy chunks load from the same runtime
endpoint (output.publicPath = /api/plugin/:id/runtime/), served as
plain .js assets
→ script calls definePlugin({ id, name, onLoad, ... })
→ window.__TEABLE_PLUGINS__[pluginId] = config
→ PluginLoader reads registration, creates PluginContext, calls onLoad(ctx)
→ ctx.addContextMenuAction(...) etc. register React components to componentRegistry
→ PluginSlot renders registered components at the right mount point
→ if manifest.devMode is true (dev loop only), the loader polls the
manifest every 4s and remounts the plugin when buildId changes —
no page refresh (production manifests never set devMode)
3. Plugin unloads
→ onUnload(ctx) called
→ ctx auto-disposes all registered resources (components, event listeners)
→ script/link elements removed
→ window.__TEABLE_PLUGINS__[pluginId] deleted
```
## Key host modules
### HostInitializer
Mounts inside `<AppProvider>` (after SDK init). Runs once — writes all host APIs
to `window.__TEABLE_HOST__`. The object is immutable after initialization.
Location: `apps/nextjs-app/src/features/app/components/plugin/host/host-initializer.tsx`
### PluginLoader
A React component that loads a single plugin. Handles the full lifecycle:
manifest fetch → compatibility check → CSS injection → script load →
definePlugin → onLoad → onUnload → cleanup.
Each installed plugin gets its own `<PluginLoader>` instance. The loader shows
a "Loading plugin..." message during fetch/load, and an error message on failure.
Location: `apps/nextjs-app/src/features/app/components/plugin/host/plugin-loader.tsx`
### PluginSlot
Queries the component registry for all plugins registered at a given position
and renders their components.
```tsx
<PluginSlot position="contextMenu" tableId={tableId} />
<PluginSlot position="panel" tableId={tableId} viewId={viewId} />
<PluginSlot position="dashboard" baseId={baseId} />
```
Location: `apps/nextjs-app/src/features/app/components/plugin/host/plugin-slot.tsx`
### ComponentRegistry
An in-memory registry mapping `(position, pluginId, componentId) → React.FC`.
Plugins register components during `onLoad` and the registry auto-cleans up on
plugin unload.
Location: `apps/nextjs-app/src/features/app/components/plugin/host/component-registry.ts`
### EventBus
A simple pub/sub event bus shared between the host and all plugins. Plugins
subscribe via `ctx.on(...)` and emit via `ctx.emit(...)`.
Events include: `record.created`, `record.updated`, `record.deleted`,
`view.changed`, `selection.changed`, `plugin.activated`.
Location: `apps/nextjs-app/src/features/app/components/plugin/host/event-bus.ts`
## Plugin bundle structure
```
dist/
├── main.js # IIFE entry bundle (webpack/vite output)
├── <name>-<hash>.chunk.js # optional lazy chunks (code splitting) — FLAT names
├── *.js.map # optional source maps (served as application/json)
└── styles.css # optional stylesheet (Tailwind/SCSS output OK)
plugin.json # manifest
```
## Shared dependency bridging
Plugin webpack config marks ONLY the React singleton as external, mapping to
a host global:
```javascript
externals: {
'react': 'window.__TEABLE_HOST__.React',
'react-dom': 'window.__TEABLE_HOST__.ReactDOM',
}
```
Everything else on the host (`SDK`, `UILib`, `OpenAPI`, `PluginSDK`) is read
from `window.__TEABLE_HOST__` at runtime — there is NO working externals
mapping for `@teable/*` packages (a value import via the old
window-global-externals style compiled to `undefined`; via monorepo source
aliases it would inline the whole package source — see troubleshooting.md
Pitfall 1). `react/jsx-runtime` is intentionally NOT external: the jsx
automatic transform bundles it, which is safe — it only creates element
objects, while hooks/context/state still resolve through the externalized
`react` → host singleton.
Other npm dependencies (react-router, charts, lodash…) are bundled into the
plugin as normal.
## Two ways to access host APIs
### 1. Through `window.__TEABLE_HOST__` (the only runtime path)
```javascript
const host = window.__TEABLE_HOST__;
const React = host.React;
const { useFields, useTableId } = host.SDK;
const { Button } = host.UILib;
const { getRecords } = host.OpenAPI;
const { definePlugin, useSelection } = host.PluginSDK;
```
This is what `plugins/plugin-showcase/` and `plugins/batch-edit-pro/` use —
the plugin bundle has no `@teable/*` runtime imports, everything resolves
through the host global. `import { useState } from 'react'` also works (the
externals mapping above redirects it to the host singleton).
### 2. `import type` from `@teable/*` (type-checking only)
```typescript
import type { ITeableHost } from '@teable/plugin-sdk';
export const host = window.__TEABLE_HOST__ as unknown as ITeableHost;
```
`import type` is erased at compile time — it never reaches the bundle. This
is how scaffolded projects get full type safety inside the monorepo
(standalone scaffolds ship a loose-typed local host module instead).
## Positions and registration
| Position | Mount point | Registration method |
|---|---|---|
| ContextMenu | Right-click → "Plugins" group in context menu | `ctx.addContextMenuAction(id, component)` |
| Panel | Right-side drawer of a table view | `ctx.addPanel(id, component)` |
| View | View-type dropdown (alongside grid/form/gallery) | `ctx.addView(id, component)` |
| Dashboard | Grid card on space dashboard | `ctx.addDashboardWidget(id, component)` |
| Route | Full-page app at `/base/:baseId/p/:pluginId` (whole viewport inside BaseLayout; own router OK, HashRouter recommended) | `ctx.addRoute(id, component)` |
@@ -0,0 +1,428 @@
# Plugin SDK API Reference
The SDK lives at `packages/plugin-sdk/` and is also exposed as
`window.__TEABLE_HOST__.PluginSDK`.
## Registration — `definePlugin`
The single entry point for every plugin. Must be called at the top level of the
IIFE bundle.
```typescript
definePlugin({
id: 'my-plugin', // unique, lowercase-kebab
name: 'My Plugin', // display name
version: '1.0.0', // semver
minAppVersion: '1.0.0', // minimum host version required
onLoad(ctx: IPluginContext) {
// Register components, subscribe to events
ctx.addContextMenuAction('myAction', MyComponent);
ctx.on('selection.changed', (payload) => {
console.log('Selection changed:', payload.selection);
});
},
onUnload?(ctx: IPluginContext) {
// Optional manual cleanup; ctx auto-disposes all registered resources
},
});
```
`onUnload` is optional — `ctx` tracks all registered components and event
listeners and disposes them automatically on unload.
## PluginContext
Available as `ctx` in `onLoad` and `onUnload`.
### `ctx.pluginId: string`
The plugin's unique ID.
### `ctx.baseId: string`
The current base ID.
### `ctx.tableId?: string`
The current table ID (if the plugin is loaded in a table context).
### `ctx.viewId?: string`
The current view ID (if available).
### Component Registration
Each method takes a unique `id` (per-plugin scope) and a React component.
Returns a `Disposable` that can be used to manually unregister before unload
(rarely needed — ctx auto-disposes).
```typescript
ctx.addContextMenuAction(id: string, component: React.FC): Disposable;
ctx.addPanel(id: string, component: React.FC): Disposable;
ctx.addDashboardWidget(id: string, component: React.FC): Disposable;
ctx.addView(id: string, component: React.FC): Disposable;
ctx.addRoute(id: string, component: React.FC): Disposable;
```
**Route position** (full-page apps, since 2026-10): the component renders at
`/base/<baseId>/p/<pluginId>` — the whole main-area viewport inside the base
layout, so SDK hooks like `host.SDK.useBaseId()` work. Install the plugin at
position `"route"` (`POST /api/base/:baseId/plugin-installs`) and include
`"route"` in `manifest.positions`. The plugin brings its own router — use
HashRouter (e.g. `react-router-dom` bundled as a normal npm dep) so Next.js
routing is untouched. Working example: `plugins/plugin-showcase/src/App.tsx`.
### Events
#### `ctx.on(event, handler): Disposable`
Subscribe to host events. Returns a `Disposable` that unsubscribes when called.
All subscriptions are auto-disposed on unload.
```typescript
// Subscribe
const sub = ctx.on('selection.changed', (payload) => {
console.log('Selection:', payload.selection);
});
// Unsubscribe early if needed
sub.dispose();
```
#### `ctx.emit(event, ...args): void`
Emit custom events to the host event bus. Other plugins can listen.
```typescript
ctx.emit('my-custom-event', { data: 'hello' });
```
### Typed Events
| Event | Payload | When emitted |
|---|---|---|
| `record.created` | `{ tableId, recordId }` | A record is created |
| `record.updated` | `{ tableId, recordId, fieldIds }` | A record is updated |
| `record.deleted` | `{ tableId, recordId }` | A record is deleted |
| `view.changed` | `{ viewId, tableId }` | User switches views |
| `selection.changed` | `{ selection }` | Row selection changes |
| `plugin.activated` | `{ pluginId, position }` | A plugin component is mounted |
### Storage
Per-install persistent storage backed by `localStorage`. Namespaced to
`plugin:<pluginId>:<key>`.
```typescript
// Read (synchronous)
const config = ctx.storage.get<MyConfig>('config');
// Write (async, returns Promise)
await ctx.storage.set('config', { theme: 'dark', ... });
// Delete
await ctx.storage.delete('config');
```
## PluginSDK on window.__TEABLE_HOST__
### `useSelection(): ISelection | undefined`
React hook that returns the current table row selection. Returns `undefined`
if nothing is selected or if used outside a table context.
```typescript
const { useSelection } = window.__TEABLE_HOST__.PluginSDK;
const selection = useSelection();
```
### `getSelectionRecords(tableId, viewId, selection, options?): Promise<IGetSelectionRecordsVo>`
Fetch the actual record data for the current selection. Used by contextMenu
plugins to get the records the user right-clicked on.
```typescript
const { getSelectionRecords } = window.__TEABLE_HOST__.PluginSDK;
const res = await getSelectionRecords(
tableId,
viewId,
selection,
{ skip: 0, take: 200 }
);
console.log(res.records); // IRecord[]
```
Returns an empty array if nothing is selected — does NOT throw.
## Host SDK (window.__TEABLE_HOST__.SDK)
Full `@teable/sdk` namespace. Commonly used hooks:
```typescript
const host = window.__TEABLE_HOST__;
const {
useFields, // () => IField[]
useRecords, // (query?) => IRecord[]
useTableId, // () => string | undefined
useViewId, // () => string | undefined
useView, // () => IView | undefined
useFieldStaticGetter,// () => getFieldStatic
BaseSingleSelect, // UI component
FilterInput, // UI component
FilterCheckbox, // UI component
SelectTag, // UI component
getSelectColorPairs, // (color, theme) => { color, backgroundColor }
} = host.SDK;
```
## Host UILib (window.__TEABLE_HOST__.UILib)
Full `@teable/ui-lib/shadcn` namespace. Commonly used components:
```typescript
const {
Button,
Dialog,
Select,
Input,
// ... all shadcn components exported by ui-lib
} = window.__TEABLE_HOST__.UILib;
```
## Host OpenAPI (window.__TEABLE_HOST__.OpenAPI)
Full `@teable/openapi` namespace. Commonly used functions:
```typescript
const {
getRecords,
updateRecords,
createRecords,
deleteRecords,
getFields,
// ... all REST API helpers
} = window.__TEABLE_HOST__.OpenAPI;
```
These pass through the existing permission system — users can only read/write
data their role allows.
## Host Methods (window.__TEABLE_HOST__)
Top-level methods on the host object (not inside PluginSDK/SDK/UILib/OpenAPI sub-namespaces).
### `host.openFormModal(config): void`
Opens a **native form view** in a modal dialog — rendered with the EXACT
native form component tree (`FormPreviewer`/`FormCellEditor`, the same code
the built-in form view and the share page use): covers/logos, colored select
tags, date picker, attachment/link/user editors, required-field validation
with scroll-to-error, localStorage drafts, and submission through
`formSubmit` (which fires "When form submitted" automations). The plugin
specifies which form (view) to open via `formId`; the framework resolves the
table, renders the form, writes the record with the current user's
permissions, and manages the modal lifecycle (close on success, stay-open on
error).
```typescript
interface IOpenFormModalConfig {
formId: string; // target form view ID (= viewId)
defaults?: Record<string, unknown>; // prefill values, keyed by fieldId
// (overrides the form's own defaultValue)
onSubmit?: ( // hook called AFTER framework writes the record
fields: Record<string, unknown>,
createdRecord: unknown // the full created record
) => void | Promise<void>;
onClose?: () => void; // called when user cancels/closes
}
// Returns void — closing is automatic.
host.openFormModal(config);
```
**Permission**: submission runs as the current logged-in user (via
`formSubmit` — triggers `formSubmitted` automations exactly like the native
form). If the user lacks `record_create` permission on the target table, the
write fails → a toast shows the error and the modal stays open (user can fix
and retry, or cancel).
**Field rendering**: 100% native — the real `FormCellEditor` per field type
(same as the built-in form view), NOT a re-implementation.
**defaults override**: plugin `defaults` take priority over both the saved
draft and the form view's configured `defaultValue`. Keys must be `fieldId`
(not field name).
**Example**: a context-menu plugin that opens a form to log an issue for the
selected record:
```typescript
const host = window.__TEABLE_HOST__;
const { getSelectionRecords } = host.PluginSDK;
function logIssueForSelected() {
const records = getSelectionRecords(...);
const orderNumber = records[0]?.fields['fld_orderNumber'];
host.openFormModal({
formId: 'viwIssueForm', // form view ID
defaults: {
'fld_relatedOrder': orderNumber, // prefill from selected record
},
onSubmit: (fields, record) => {
console.log('Issue logged:', record.id);
// Framework already wrote the record — no need to call createRecord.
},
onClose: () => {
console.log('User cancelled');
},
});
}
```
### `host.NativeForm: React.FC<INativeFormProps>`
The embeddable sibling of `NativeGrid`: renders the full NATIVE form (same
`FormPreviewer` tree as above) inline in your own panel/view component instead
of a modal. Useful for "form embedded in a side panel" or form-style data
entry views.
**Tier-1 injection props** (all optional; base props still work unchanged):
| Prop | What it does |
|------|--------------|
| `visibleFieldIds: string[]` | Restrict + reorder rendered fields (session-only, nothing persisted; computed/lookup always excluded) |
| `hiddenFieldIds: string[]` | Hide specific fields (ignored when `visibleFieldIds` set) |
| `slots: INativeFormSlot[]` | Render custom content at `position: 'top' \| 'bottom' \| <fieldId> \| <index>`; `render(ctx)` receives `{ values, setValue, errors, fields }` |
| `interceptSubmit: (fields) => fields` | Transform the payload before submit — **the native submit (and formSubmitted automations) still runs** |
| `submitOverride: (fields) => Promise` | Replace submit entirely — **formSubmitted automation will NOT fire** |
**Virtual fields pattern**: store plugin-only values under `__virtual.<key>`
via `ctx.setValue` in a slot; they are auto-stripped before `submitOverride`
(and should be removed in `interceptSubmit` if you read them there).
```typescript
const { NativeForm } = window.__TEABLE_HOST__;
function MyFormPanel() {
return (
<NativeForm
formId="viwIssueForm"
hiddenFieldIds={['fld_internalNote']]}
slots={[
{
position: 'bottom',
render: ({ values, setValue }) => (
<label>
<input
type="checkbox"
checked={Boolean(values['__virtual.urgent'])}
onChange={(e) => setValue('__virtual.urgent', e.target.checked)}
/>
加急处理
</label>
),
},
]}
interceptSubmit={(fields) => {
const next = { ...fields };
const urgent = next['__virtual.urgent'];
delete next['__virtual.urgent'];
if (urgent) next.fld_priority = 'high'; // map virtual → physical
next.fld_internalNote = 'auto-injected'; // fill a hidden field
return next;
}}
onSubmitted={(fields, record) => console.log('created', record)}
/>
);
}
```
### `host.NativeFormScope` + `host.NativeFormField` + `host.useNativeForm()` (Tier-2 granular kit)
Build fully custom layouts (grouping, steps, mixed tables) while every field
editor stays 100% native. `NativeFormScope` owns the state (values / localStorage
draft / required validation — same semantics as the native form); drive it with
the headless hook and render single native rows with `NativeFormField`.
```typescript
const { NativeFormScope, NativeFormField, useNativeForm } = window.__TEABLE_HOST__;
function SteppedForm() {
return (
<NativeFormScope formId="viwIssueForm">
<StepArea />
</NativeFormScope>
);
}
function StepArea() {
const form = useNativeForm(); // { ready, error, fields, values, setValue, errors, verify, reset, submit }
const [step, setStep] = useState(0);
if (!form.ready) return <Spinner />;
const stepFields = form.fields.slice(step * 3, step * 3 + 3);
return (
<div>
<h4>第 {step + 1} 步</h4>
{stepFields.map((f) => (
<NativeFormField key={f.id} fieldId={f.id} />
))}
<button onClick={() => (step === 0 ? setStep(1) : form.verify() && form.submit())}>
{step === 0 ? '下一步' : '提交'}
</button>
</div>
);
}
```
`form.submit()` goes through the native formSubmit endpoint, so
formSubmitted automations fire. Values are draft-backed (localStorage),
identical to the native form.
### `host.NativeGrid: React.FC`
A wrapper around the full native grid with all providers. Pass `externalFilter`
to filter rows (merged via AND conjunction with existing view filter) and
`selectedRecordIds` to control row selection.
```typescript
const { NativeGrid } = window.__TEABLE_HOST__;
function MyGridView({ filter }) {
return <NativeGrid externalFilter={filter} />;
}
```
### `host.events: { on, off, emit }`
Event bus for cross-plugin communication.
```typescript
host.events.on('my-event', (payload) => { ... });
host.events.emit('my-event', { data: 'hello' });
```
## Disposable pattern
All `ctx.add*` and `ctx.on` methods return a `Disposable`:
```typescript
interface Disposable {
dispose(): void;
}
```
In `onLoad`, the ctx collects all disposables. In `onUnload`, they are all
disposed automatically (components unregistered, event listeners removed).
You rarely need to call `dispose()` manually.
## Batch-edit-pro: the canonical example
See `plugins/batch-edit-pro/src/index.tsx` for a complete working plugin that:
- Uses `useTableId`, `useViewId`, `useFields` from host SDK
- Uses `useSelection` and `getSelectionRecords` from host PluginSDK
- Uses `updateRecords` from host OpenAPI
- Uses `Button`, `BaseSingleSelect`, `FilterInput`, `FilterCheckbox`, `SelectTag`
from host SDK/UILib
- Handles loading, error, empty, and applying states
- Supports singleSelect, checkbox, date, text, and number field types
- Has inline SVG icons (to avoid another dependency)
@@ -0,0 +1,149 @@
# Common pitfalls and how to avoid them
These are real issues discovered during plugin development. Read before writing
plugin logic — each one will save you debugging time.
## Pitfall 1: Importing `@teable/*` packages instead of using host globals
```typescript
// WRONG — webpack externals map these away at build time, so at runtime
// the import produces `undefined` or throws:
import { useFields } from '@teable/sdk'; // → undefined at runtime
import { Button } from '@teable/ui-lib'; // → undefined at runtime
import { definePlugin } from '@teable/plugin-sdk'; // fine (it re-exports from host)
// CORRECT — always read from the host global:
const host = window.__TEABLE_HOST__;
const { useFields } = host.SDK;
const { Button } = host.UILib;
const { definePlugin } = host.PluginSDK;
const React = host.React;
```
The webpack `externals` config in the scaffold template maps `@teable/sdk` →
`window.__TEABLE_HOST__.SDK` at build time. This means the bundled code replaces
the import with `window.__TEABLE_HOST__.SDK`, but destructuring from the import
statement doesn't survive the webpack transformation — use direct `host.*`
access instead. The only exception is `@teable/plugin-sdk` which re-exports
from `window.__TEABLE_HOST__.PluginSDK` at the level webpack expects.
**Symptoms**: `useFields is not a function`, `Cannot read properties of undefined`,
or components silently rendering nothing.
## Pitfall 2: `window.__TEABLE_HOST__` is undefined
The host object is injected by `HostInitializer` which mounts inside
`<AppProvider>`. This happens after the initial React tree render. If you try
to access `window.__TEABLE_HOST__` in a script that executes before the host
tree mounts, it will be `undefined`.
**Fix**: The `PluginLoader` component ensures the host is ready before loading
plugin scripts, so plugins loaded through the normal flow don't hit this. But
if you're testing a plugin directly (e.g. loading main.js in a browser console),
you'll see `__TEABLE_HOST__` is null until the app renders.
## Pitfall 3: Upload returns 403 — not a developer
Only users in the `plugin_developer` DB table can upload bundles. The
`DeveloperGuard` checks this. If you get 403 on `upload-bundle`, your user is
not whitelisted.
**Fix**: Add the user to `plugin_developer`:
```sql
INSERT INTO plugin_developer (id, user_id, email, created_by)
VALUES (gen_random_uuid(), '<userId>', '<email>', '<adminUserId>');
```
## Pitfall 4: Enable (plugin-config) must be called before install
Installing a plugin to a position (contextMenu / panel / view / dashboard)
requires the plugin to be enabled first. If you skip enable and call install
directly, you'll get 403.
**The correct sequence is always**:
1. Upload bundle → get pluginId
2. Enable via `PUT /base/:baseId/plugin-config/:pluginId`
3. Install to position
## Pitfall 5: `getSelectionRecords` returns empty array, not throw
The old iframe/ZIP `bridge.getSelectionRecords()` threw when nothing was
selected. The dynamic system's `PluginSDK.getSelectionRecords()` returns an
empty array instead. You still need to handle the empty case in your UI,
but you don't need a try/catch wrapper.
```typescript
const { getSelectionRecords } = host.PluginSDK;
const res = await getSelectionRecords(tableId, viewId, selection);
if (res.records.length === 0) {
// Show "no records selected" message — no try/catch needed
}
```
## Pitfall 6: Plugin component renders nothing / context menu empty
If your plugin uploads, enables, and installs successfully but the component
never appears:
1. **Check `plugin.json` `positions`** — it must include the position you're
installing to (e.g. `"positions": ["contextMenu"]`)
2. **Check `onLoad` registration** — `ctx.addContextMenuAction('main', Component)`
must be called inside `onLoad`
3. **Check Authority Matrix** — the user's role must have the plugin enabled
in AM. For the test account, the owner bypasses AM, but for other users,
an admin must enable the plugin per role in the RoleEditor UI.
4. **Check browser console** — errors in `onLoad` are silently caught; add a
`console.log` at the top of `onLoad` to verify it runs.
## Pitfall 7: Component uses JSX but webpack doesn't transpile it
The scaffold template uses `ts-loader` with `jsx: "react-jsx"` in tsconfig.
If you're setting up the webpack config manually, make sure:
- `tsconfig.json` has `"jsx": "react-jsx"` under `compilerOptions`
- The plugin does NOT need `import React from 'react'` at the top when using
`react-jsx` transform — JSX is auto-transpiled. But you still need
`const React = host.React` for any explicit `React.createElement` calls.
## Pitfall 8: Chunk 404 at runtime — multipart strips directory names (2026-10)
`webpack output.chunkFilename: 'static/js/[name].chunk.js'` builds fine, the
upload succeeds, but the browser 404s the chunk: multer/busboy flattens
multipart filenames, so the server stored `469.chunk.js` while the bundle
requests `static/js/469.chunk.js`.
**Fix**: keep chunk names FLAT — `chunkFilename: '[name]-[contenthash].chunk.js'`
(contenthash prevents collisions). Public path still works:
`output.publicPath: '/api/plugin/<id>/runtime/'`.
## Pitfall 9: `Bundle must be IIFE format` after a successful build
Three producer-side causes, all seen in the wild:
1. **Terser license banner** — bundling any npm package with license comments
makes webpack emit `/*! For license information... */` BEFORE `!function`.
Fix: `optimization.minimizer: [new TerserPlugin({ extractComments: false })]`.
2. **Development-mode webpack** output starts with `/******/` banner comments.
The dev watch loop therefore builds with `--mode production`.
3. **Vite/esbuild IIFE** output starts `var <name>=(function...` — the
validator accepts it (since 2026-10); no post-processing needed.
## Pitfall 10: vite scaffold `pnpm install` fails with ERR_PNPM_IGNORED_BUILDS
pnpm ≥ 10/11 blocks esbuild's postinstall and (on patched hosts) treats it as
fatal. Fix (scaffold generates it): `pnpm-workspace.yaml` with
`allowBuilds:\n esbuild: true`. esbuild also works without its script (binary
comes from optionalDependencies) — only the pnpm gate needs the allow entry.
## Pitfall 11: PAT gets 403 fetching `/api/plugin/:id/runtime/*`
Access tokens only pass routes with `@TokenAccess()` or `@Permissions` — the
runtime asset serving has neither. This is by design (the browser loads with a
session). For curl verification use a signed `auth_session` cookie; for uploads
use the PAT (`upload-bundle` and `POST /api/plugin` are token-accessible).
## Pitfall 12: "react/jsx-runtime would duplicate React" worry
It does not. The jsx automatic transform imports `react/jsx-runtime`, which is
bundled into the plugin on purpose: it only creates plain element objects.
Hooks/context/state go through the externalized `react` → host singleton. This
is exactly how the deployed ticket-console/batch-edit-pro bundles behave.
@@ -0,0 +1,7 @@
{
"version": "1.1.0",
"organization": "inkreach",
"authors": ["inkreach"],
"tags": ["plugin", "teable", "bundle", "iife", "webpack", "vite", "context-menu", "dashboard", "panel", "view", "route", "hot-reload", "source-map"],
"abstract": "Develop dynamic React component plugins for inkreach-teable: IIFE bundles (webpack or vite) loaded at runtime via window.__TEABLE_HOST__ with 5 mount positions including full-page route apps, multi-asset uploads (lazy chunks + source maps), a watch-and-hot-reload dev loop, and the teable-plugin CLI scaffold."
}
@@ -1,16 +1,7 @@
--- ---
name: inkreach-teable name: inkreach-teable
description: >- description: >-
Operate a deployed inkreach-teable instance (a Teable fork with a self-built Use this skill for hands-on work on a deployed inkreach-teable instance (a self-hosted Teable fork with its own automation engine — not teable cloud): adding or altering fields and views, cleaning or bulk-updating records, aggregating table data (monthly counts, group-bys), building automations that react to record changes, form submissions, buttons, or schedules and that can call external APIs, storing API keys as secrets so they aren't hardcoded, checking run history, and configuring change-summary email subscriptions. Trigger whenever the user names a business table (工单表/订单表/成员表, or any bse/tbl/fld/rec/viw/wfl ID) and wants to configure, query, script, automate, or subscribe to it — even if they never say "teable". Also use for this instance's SQL/REST access. Don't use for teable cloud pricing/comparisons, generic SQL/MySQL tuning, standalone Python/npm/CLI scripting, or unrelated webhook integrations.
automation engine) via the inkreach-teable CLI: configure tables, fields,
records, views, and automations (8 triggers incl. recordCreated/Updated/
Condition, formSubmitted, scheduledTime, buttonClick, webhook; 7 actions),
inspect field constraints (select choices, visibilityFilter conditions),
manage secrets, execution history, plugins, and record subscriptions. Use
when the user references Teable-style IDs (bseXXX, tblXXX, fldXXX, recXXX,
viwXXX, wflXXX) or wants to configure tables/automations/plugins on the
deployed instance — even without saying "inkreach-teable". The official
teable CLI does NOT work here (different automation contract).
--- ---
# inkreach-teable operations # inkreach-teable operations
@@ -18,14 +9,21 @@ description: >-
Operate a deployed inkreach-teable instance via the `inkreach-teable` CLI. Operate a deployed inkreach-teable instance via the `inkreach-teable` CLI.
Respond in the user's language. Keep answers concise and action-oriented. Respond in the user's language. Keep answers concise and action-oriented.
## 0. 执行守则
- 多步任务先规划再动手;**完成全部步骤后才给最终答复**,不要中途停止或只给命令不给结果。
- 完成后如实汇报:做了什么、关键命令、结果;**做不到就明说做不到及原因,绝不谎报成功**。
- 长任务(批量记录、多工作流)分批执行,每批确认后再继续。
- **改错了怎么撤销**:workflow 的 snapshot 仅供审计,**没有恢复 API**——用 `workflow deactivate` 止损,再用 `trigger set` / `action update` 改回(节点更新是全图替换,直接改回正确配置即可);整条不要了用 `workflow delete`(软删)。
> **Critical**: the automation engine is **self-built and differs from official > **Critical**: the automation engine is **self-built and differs from official
> teable** — the official `teable` CLI corrupts or 404s on automations here. > teable** — the official `teable` CLI corrupts or 404s on automations here.
> Exact key-name differences are in [Key Constraints](#3-key-constraints). > Exact key-name differences are in [Key Constraints](#3-key-constraints).
## 1. Prerequisites ## 1. Prerequisites
- Install the CLI if missing: `bash scripts/install.sh` (relative to this skill dir). - Install the CLI if missing: `npm i -g @inkreach/inkreach-teable` (public npm package). Offline fallback: `bash scripts/install.sh` relative to this skill dir in a repo clone.
- **Configure once**: `inkreach-teable auth login --endpoint http://localhost:3003 --token <PAT> [--base-id bseXXX]`; verify with `auth status`. PAT comes from `auth token create --name x` (full token shown **once**) or the web UI (Settings → Personal Access Tokens). - **Configure once**: `inkreach-teable auth login --endpoint https://table.inkreach.cc --token <PAT> [--base-id bseXXX]`; verify with `auth status`. PAT comes from `auth token create --name x` (full token shown **once**) or the web UI (Settings → Personal Access Tokens).
- `--json` on any command returns raw JSON (for agent parsing). Fallback for unknown commands: `inkreach-teable <cmd> --help`. - `--json` on any command returns raw JSON (for agent parsing). Fallback for unknown commands: `inkreach-teable <cmd> --help`.
- **Read on demand**: consult the guide linked for your module when doing non-trivial work — no need to read everything up front. - **Read on demand**: consult the guide linked for your module when doing non-trivial work — no need to read everything up front.
@@ -34,14 +32,14 @@ Respond in the user's language. Keep answers concise and action-oriented.
| Module | What it solves | Entry commands | Detail doc | | Module | What it solves | Entry commands | Detail doc |
|--------|---------------|----------------|------------| |--------|---------------|----------------|------------|
| Tables & Fields | Create/modify structure; **inspect select-choice constraints** | `table create`, `field create`, `field list --with-options`, `field get` | [tables-fields.md](guides/tables-fields.md), [field-types.md](api-reference/field-types.md) | | Tables & Fields | Create/modify structure; **inspect select-choice constraints** | `table create`, `field create`, `field list --with-options`, `field get` | [tables-fields.md](guides/tables-fields.md), [field-types.md](api-reference/field-types.md) |
| Records | Read/write row data | `record get`, `record create/update/delete` | [tables-fields.md § Records](guides/tables-fields.md) | | Records | Read/write row data | `record get` (alias `record list`), `record create/update/delete` | [tables-fields.md § Records](guides/tables-fields.md) |
| Views | Filtered/sorted perspectives | `view create/update` | [tables-fields.md § Views](guides/tables-fields.md) | | Views | Filtered/sorted perspectives | `view create/update` | [tables-fields.md § Views](guides/tables-fields.md) |
| View presets | View-scoped shared filter/sort bundles + per-user personal config, applied via toolbar | `preset create/update/list/delete` | [tables-fields.md § View Presets](guides/tables-fields.md) | | View presets | View-scoped shared filter/sort bundles + per-user personal config, applied via toolbar | `preset create/update/list/delete` | [tables-fields.md § View Presets](guides/tables-fields.md) |
| Automation | Event-driven workflows | `workflow *`, `trigger set`, `action add` | [automation-guide.md](guides/automation-guide.md), [trigger-types.md](api-reference/trigger-types.md), [action-types.md](api-reference/action-types.md) | | Automation | Event-driven workflows | `workflow *`, `trigger set`, `action add` | [automation-guide.md](guides/automation-guide.md), [trigger-types.md](api-reference/trigger-types.md), [action-types.md](api-reference/action-types.md) |
| Secrets | API keys for script actions | `secret set/list/delete` | [automation-guide.md § Secrets](guides/automation-guide.md) | | Secrets | API keys for script actions | `secret set/list/delete` | [automation-guide.md § Secrets](guides/automation-guide.md) |
| Execution History | Run results / rerun failures | `runs list/get/rerun` | [automation-guide.md § Testing](guides/automation-guide.md) | | Execution History | Run results / rerun failures | `runs list/get/rerun` | [automation-guide.md § Testing](guides/automation-guide.md) |
| Manual Trigger | Test button-click/webhook | `fire button-click`, `fire webhook` | [automation-guide.md § Testing](guides/automation-guide.md) | | Manual Trigger | Test button-click/webhook | `fire button-click`, `fire webhook` | [automation-guide.md § Testing](guides/automation-guide.md) |
| SQL | Read-only analytics, JOINs | `sql '<sql>'` | [cli-reference.md § sql](guides/cli-reference.md) | | Direct REST API | Integrate external scripts/services; endpoints the CLI lacks; script actions calling back | `curl` / `fetch` + `Authorization: Bearer <PAT>` | [rest-api.md](api-reference/rest-api.md) |
| Plugins | Dynamic plugins, dev whitelist, CSP | `plugin *` | [cli-reference.md § plugin](guides/cli-reference.md) | | Plugins | Dynamic plugins, dev whitelist, CSP | `plugin *` | [cli-reference.md § plugin](guides/cli-reference.md) |
| Subscriptions | Record digest/instant email | `subscription *` | [subscription.md](guides/subscription.md) | | Subscriptions | Record digest/instant email | `subscription *` | [subscription.md](guides/subscription.md) |
| Dev/Deploy | Build, restart, gotchas | systemd / pnpm | [development.md](guides/development.md) | | Dev/Deploy | Build, restart, gotchas | systemd / pnpm | [development.md](guides/development.md) |
@@ -57,14 +55,17 @@ Full command syntax: [cli-reference.md](guides/cli-reference.md). Instance acces
| React to a condition matching | Trigger `recordCondition` (NOT recordMatchesConditions) | Official-teable key names | | React to a condition matching | Trigger `recordCondition` (NOT recordMatchesConditions) | Official-teable key names |
| Button click starts a workflow | `buttonClick` trigger + Button field (`options.workflow` object) | Other trigger types | | Button click starts a workflow | `buttonClick` trigger + Button field (`options.workflow` object) | Other trigger types |
| Receive external HTTP | `webhook`; test via `fire webhook` | Expecting auth (backend ignores it) | | Receive external HTTP | `webhook`; test via `fire webhook` | Expecting auth (backend ignores it) |
| Schedule recurring job | `scheduledTime` (only `timing.type` + `interval` honored) | triggerTime/weekdays (ignored) | | Schedule recurring job | `scheduledTime` (only `timing.type` + `interval` honored). If the user asks for specific clock times or weekdays, say up front that the backend ignores them and propose an interval instead | triggerTime/weekdays (ignored silently — configuring them looks fine but never fires as intended) |
| Per-row transformation | `script` action reading `triggerInput` | Row-by-row record API | | Per-row transformation | `script` action reading `triggerInput` | Row-by-row record API |
| Send notification | `sendNotification` (in-app) | `sendEmail` (needs SMTP) unless email required | | Send notification | `sendNotification` (in-app) | `sendEmail` (needs SMTP) unless email required |
| **Inspect select-choice constraints** | `field list --with-options`, `field get <tbl> <name>`, `field visible-choices` | `--json` + manual filtering | | **Inspect select-choice constraints** | `field list --with-options`, `field get <tbl> <name>`, `field visible-choices` | `--json` + manual filtering |
| Read records for later writes | `record get` (returns record IDs) | `sql` (no record IDs) | | **Write a select-field value** | Verify the value exists in choices first (`field list --with-options`); need a new value → `field update --options` to add the choice, then write | Blind `record update` (400, or typecast silently resolves to null / mutationApplied=false — it never creates choices) |
| Analytics / JOIN | `sql` with GROUP BY | Fetch all records + compute | | Read records for later writes | `record get` / `record list` (returns record IDs) | Official-teable SQL APIs (none here) |
| **Attach a remote file to a record** | `record create/update --fields '{"Files":[{"url":"https://…"}]}'` (backend downloads & stores it) | The multipart `uploadAttachment` route or manual signature/notify upload (only needed for local files) |
| Analytics / aggregation | `record get --filter` + compute, script action, or grouped grid view | `sql` — this deployment has NO SQL query API (CLI 0.1.5 removed the dead command) |
| Store an API key for scripts | `secret set` → `process.env.NAME` | Hardcoding | | Store an API key for scripts | `secret set` → `process.env.NAME` | Hardcoding |
| Workflow didn't run | `runs list --status failed` → `runs get` | Blind re-activate | | Workflow didn't run | `runs list --status failed` → `runs get` | Blind re-activate |
| Call the instance from external code / a route the CLI lacks | Direct REST: `Authorization: Bearer <PAT>` + routes in [rest-api.md](api-reference/rest-api.md); unknown route → run the nearest CLI command with `--debug` and copy its request | Official teable API docs (different automation contract) |
## 3. Key Constraints ## 3. Key Constraints
@@ -82,17 +83,23 @@ These differ from official teable and from the (stale) `docs/automation/triggers
10. **Button field binding**: `options.workflow` is an **object** `{id, name?}`, not a bare id string. The field's `isActive` snapshot can go stale; the API validates the workflow's DB `isActive` at click time (deactivated → 400). 10. **Button field binding**: `options.workflow` is an **object** `{id, name?}`, not a bare id string. The field's `isActive` snapshot can go stale; the API validates the workflow's DB `isActive` at click time (deactivated → 400).
11. **Script sandbox** has NO `fs`/`eval`/`Function`, but has `triggerInput`, `console`, `fetch`, `process.env` (secrets + `AUTOMATION_TOKEN` + `PUBLIC_ORIGIN`), and a controlled `require`. Call back into the instance via `triggerInput._accessToken` + `triggerInput._apiBaseUrl`. **npm deps**: declare with `action add/update --dependencies '[{"name":"axios","version":"1.6.0"}]'` (or `axios@1.6.0,dayjs`). Requires `npm` on the backend **service** PATH — systemd user units default to a PATH that omits the nvm bin dir, so deps fail with `spawn npm ENOENT`; fix by adding `Environment=PATH=<nvm-bin>:/usr/local/sbin:...` to `teable-backend.service`. 11. **Script sandbox** has NO `fs`/`eval`/`Function`, but has `triggerInput`, `console`, `fetch`, `process.env` (secrets + `AUTOMATION_TOKEN` + `PUBLIC_ORIGIN`), and a controlled `require`. Call back into the instance via `triggerInput._accessToken` + `triggerInput._apiBaseUrl`. **npm deps**: declare with `action add/update --dependencies '[{"name":"axios","version":"1.6.0"}]'` (or `axios@1.6.0,dayjs`). Requires `npm` on the backend **service** PATH — systemd user units default to a PATH that omits the nvm bin dir, so deps fail with `spawn npm ENOENT`; fix by adding `Environment=PATH=<nvm-bin>:/usr/local/sbin:...` to `teable-backend.service`.
12. **Field options updates go through the convert API** — `field update --options` handles this; the plain PATCH update API strips `options`. 12. **Field options updates go through the convert API** — `field update --options` handles this; the plain PATCH update API strips `options`.
13. **No SQL query API exists** — the backend has no SQL endpoint; the CLI `sql` command was removed in 0.1.5. Aggregate via `record get --filter` + a script action, or a grouped grid view. Also: `table get/update` are base-scoped (`--base-id`, defaults to the configured base) and `view get/update/delete` + `record get-one` are table-scoped (`--table-id`).
## 4. Quick Syntax ## 4. Quick Syntax
```bash ```bash
inkreach-teable auth login --endpoint http://localhost:3003 --token <PAT> # once inkreach-teable auth login --endpoint https://table.inkreach.cc --token <PAT> # once
inkreach-teable table create --name "Tasks" --fields '[{"name":"Title","type":"text"},{"name":"Status","type":"singleSelect","options":{"choices":["Todo","Done"]}}]' inkreach-teable table create --name "Tasks" --fields '[{"name":"Title","type":"text"},{"name":"Status","type":"singleSelect","options":{"choices":["Todo","Done"]}}]'
inkreach-teable record create --table-id tblXXX --fields '{"Title":"a"}' # 单条;批量用 --records '[{"fields":{...}}]'
inkreach-teable record create --table-id tblXXX --fields '{"Files":[{"url":"https://…/f.png"}]}' # 附件字段直接吃 URL(服务端下载入库);字符串形式加 --typecast
inkreach-teable record list --table-id tblXXX [--filter '<json>'] # 查询/分页(= record get)
inkreach-teable record delete --table-id tblXXX recXXX [recYYY ...] # 也可 --record-ids 'recA,recB'
inkreach-teable workflow create --name "On Go click" # → wflXXX inkreach-teable workflow create --name "On Go click" # → wflXXX
inkreach-teable trigger set wflXXX --type buttonClick --table-id tblXXX --field-ids '["fldXXX"]' inkreach-teable trigger set wflXXX --type buttonClick --table-id tblXXX --field-ids '["fldXXX"]'
inkreach-teable action add wflXXX --type script --code 'return triggerInput.record.fields' inkreach-teable action add wflXXX --type script --code 'return triggerInput.record.fields'
inkreach-teable workflow activate wflXXX inkreach-teable workflow activate wflXXX
inkreach-teable fire button-click tblXXX recXXX fldXXX && inkreach-teable runs list wflXXX inkreach-teable fire button-click tblXXX recXXX fldXXX && inkreach-teable runs list wflXXX
# webhook 触发器的外部调用地址(无鉴权,激活后生效): {endpoint}/api/webhook/{baseId}/{workflowId} —— 用 fire webhook <baseId> <wflXXX> 本地测试
``` ```
## 5. Common Errors & Recovery ## 5. Common Errors & Recovery
@@ -108,3 +115,6 @@ inkreach-teable fire button-click tblXXX recXXX fldXXX && inkreach-teable runs l
| `runs list` shows failed | Action error | `runs get <wfId> <execId>` → `nodeResults[].error` | | `runs list` shows failed | Action error | `runs get <wfId> <execId>` → `nodeResults[].error` |
| Script deps fail `spawn npm ENOENT` | `npm` not on service PATH | See Key Constraint 11 | | Script deps fail `spawn npm ENOENT` | `npm` not on service PATH | See Key Constraint 11 |
| Record write 400 "does not meet the current row conditions" | Value violates choice `visibilityFilter` | `field visible-choices` to see allowed options | | Record write 400 "does not meet the current row conditions" | Value violates choice `visibilityFilter` | `field visible-choices` to see allowed options |
| `sql: unknown command` / 404 on a SQL endpoint | No SQL query API on this deployment | Aggregate via script action or `record get --filter` |
| `record list` works like `record get` | Intentional alias (CLI ≥0.1.5) | — |
| Select-field write "succeeds" but value unchanged (or becomes null) | Value not in choices — without typecast the API 400s; with typecast it resolves to null and no-ops (`mutationApplied=false`), never creates the choice | `field list --with-options` to confirm; `field update --options` to add the choice, then write |
@@ -21,8 +21,7 @@ Complete reference of all Teable field types, aliases, option shapes, and smart
| `date` | Date | Date or datetime | No | | `date` | Date | Date or datetime | No |
| `checkbox` | Checkbox | Yes/no toggle | No | | `checkbox` | Checkbox | Yes/no toggle | No |
| `rating` | Rating | Star/numeric rating 1..10 | No (defaults: max=5, star, yellow) | | `rating` | Rating | Star/numeric rating 1..10 | No (defaults: max=5, star, yellow) |
| `attachment` | Attachment | Files, images, documents | No | | `attachment` | Attachment | Files, images, documents; cell values also accept `{"url":"https://…"}` items (server downloads the file) | No || `user` | User | Person/collaborator reference | No |
| `user` | User | Person/collaborator reference | No |
| `link` | Link | Reference records in another table | Yes (`foreignTableId`/`foreignTableName`) | | `link` | Link | Reference records in another table | Yes (`foreignTableId`/`foreignTableName`) |
| `lookup` | Lookup | Mirror a field from linked record | Yes (`lookupOptions`) | | `lookup` | Lookup | Mirror a field from linked record | Yes (`lookupOptions`) |
| `rollup` | Rollup | Aggregate linked records (SUM, COUNT, etc.) | Yes (expression + `lookupOptions`) | | `rollup` | Rollup | Aggregate linked records (SUM, COUNT, etc.) | Yes (expression + `lookupOptions`) |
@@ -410,6 +409,7 @@ When `--type` is omitted, the CLI infers the type from the field name:
| `user` | `{"id":"usrXXX","title":"Alice"}` | `"Alice"` or `"alice@example.com"` | | `user` | `{"id":"usrXXX","title":"Alice"}` | `"Alice"` or `"alice@example.com"` |
| `link` (single) | `{"id":"recXXX"}` or `[{"id":"recXXX"}]` | `"Record Display Name"` | | `link` (single) | `{"id":"recXXX"}` or `[{"id":"recXXX"}]` | `"Record Display Name"` |
| `link` (multi) | `[{"id":"recA"},{"id":"recB"}]` | `["Record A","Record B"]` | | `link` (multi) | `[{"id":"recA"},{"id":"recB"}]` | `["Record A","Record B"]` |
| `attachment` | `[{"url":"https://…/f.png","name":"f.png"}]` (V1 legacy bases: token items only) | `"https://…/f.png"` or `"https://…/a.png,actXXX"` (comma-separated urls/ids) |
**When to use**: always use `--typecast` when creating/updating records with user or link fields — it eliminates manual ID lookups. **When to use**: always use `--typecast` when creating/updating records with user or link fields — it eliminates manual ID lookups.
@@ -0,0 +1,222 @@
# REST API Reference (direct HTTP)
How to call the inkreach-teable backend API directly with plain HTTP (curl /
node:http / axios / any language) — no CLI involved. The CLI wraps exactly
these routes, so everything it does is reproducible raw.
**Use direct API when**: integrating external scripts/services, the CLI lacks
an endpoint (auth signup, share views…), writing automation script actions
that call back into the instance, or working from a language/runtime without
the CLI. For one-off table/automation operations, prefer the CLI.
## 1. Base URL & Authentication
```bash
BASE=https://table.inkreach.cc
TOKEN=teable_xxx_yyy # PAT; web UI Settings → Personal Access Tokens
curl -s "$BASE/api/auth/user" -H "Authorization: Bearer $TOKEN" # verify → 200 + user JSON
```
- Every authenticated call: `Authorization: Bearer <PAT>` (+ `Content-Type:
application/json` on bodies). PAT scopes ∩ base role = effective permission.
- Discover the configured base/token the CLI already uses:
`inkreach-teable auth status`, or read `~/.inkreach-teable/config.json`.
- ⚠️ **Node v24 global `fetch()` (undici) can silently drop the Authorization
header** — use `curl`, `node:http`, or `axios` instead (details in
[development.md](../guides/development.md)).
## 2. Endpoint Map
Verified route-for-route against `src/lib/client.ts` (the CLI's HTTP layer).
### Records
| Method & route | Purpose |
|---|---|
| `GET /api/table/{tid}/record` | List/query records (params below) |
| `POST /api/table/{tid}/record` | Create one (`{fields}`) or many (`{records:[{fields}]}`) |
| `PATCH /api/table/{tid}/record` | Batch update — `{records:[{id, fields}]}` |
| `GET /api/record/{rid}` | Fetch one record |
| `PATCH /api/record/{rid}` | Update one — `{fields}` |
| `DELETE /api/table/{tid}/record?recordIds=recA&recordIds=recB` | Delete by ids |
List-query params (objects are passed **JSON-stringified**; repeated keys use
`key[]`): `filter` (JSON), `projection` (JSON array), `search[]` (keyword —
MUST be the `search[]` tuple form, plain `search=` is ignored), `skip` / `take`
(pagination; no `orderBy` on this fork — sort via a view). Response:
`{ records: [{id, fields}], total? }`.
### Fields
| Method & route | Purpose |
|---|---|
| `GET`/`POST` `/api/table/{tid}/field` | List / create |
| `GET`/`PATCH`/`DELETE` `/api/table/{tid}/field/{fid}` | Read / update / delete |
| `PUT /api/table/{tid}/field/{fid}/convert` | Change type **or options** (PATCH strips options!) |
| `GET /api/table/{tid}/select-visibility/{rid}/{fid}` | Choices visible to a given row (visibilityFilter) |
### Tables / Views / Presets
| Method & route | Purpose |
|---|---|
| `GET`/`POST` `/api/base/{bid}/table` | List / create (body `{name, fields:[…]}`) |
| `GET`/`PUT` `/api/table/{tid}`, `DELETE /api/base/{bid}/table/{tid}` | Read-update / delete table |
| `GET`/`POST` `/api/table/{tid}/view`, `GET`/`PATCH`/`DELETE` `/api/view/{vid}` | View CRUD |
| `GET`/`POST` `/api/table/{tid}/view-preset`, `PUT`/`DELETE` `…/view-preset/{pid}` | Shared view presets |
### Automation (self-built engine — `bid` = base id)
| Method & route | Purpose |
|---|---|
| `GET`/`POST` `/api/base/{bid}/workflow` | List / create (`{name, description?}`) |
| `GET` `/api/base/{bid}/workflow/{wid}` or `/api/base/any/workflow/{wid}` | Read one (any-variant works cross-base) |
| `PATCH`/`DELETE` `/api/base/{bid}/workflow/{wid}` | Update metadata / soft delete |
| `POST` `…/workflow/{wid}/activate` \| `…/deactivate` | Activate (creates snapshot) / deactivate |
| `PUT` `…/workflow/{wid}/node` | **Whole-graph** node replacement (`{nodes:[…]}`) |
| `POST` `…/workflow/{wid}/test` | Dry-run → `{nodeResults}` |
| `GET`/`POST` `…/workflow/{wid}/secret`, `PUT`/`DELETE` `…/secret/{sid}` | Secrets (write-only values) |
| `GET` `…/workflow/{wid}/execution`, `GET` `…/execution/{eid}`, `POST` `…/execution/{eid}/rerun` | Run history / rerun |
| `POST` `/api/table/{tid}/record/{rid}/{fid}/button-click` | Fire a button field |
| `POST` `/api/webhook/{bid}/{wid}` | Fire a webhook (⚠️ real route — the UI shows `/api/webhook/base/{bid}/workflow/{wid}` which is WRONG; `authorization` is never validated) |
### Misc
| Method & route | Purpose |
|---|---|
| `GET /api/table/{tid}/aggregation/row-count` | Total row count (`{"rowCount":N}` — verified) |
| `GET /api/table/{tid}/aggregation…` | Stats family (group-points, calendar-daily, search-count…) — see swagger for params |
| `GET`/`POST` `/api/access-token`, `DELETE /api/access-token/{id}` | PAT management |
| `POST /api/auth/signup` | Create account (open on this instance) |
## 3. Canonical Examples
```bash
BASE=https://table.inkreach.cc; AUTH="Authorization: Bearer $TOKEN"
# create records (single + bulk). typecast: accepts strings for non-text
# fields; fieldKeyType 'id' = keys are fld ids ('name' = field names)
curl -s -X POST "$BASE/api/table/tblXXX/record" -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"fieldKeyType":"id","typecast":true,"records":[{"fields":{"fldTitle":"hello","fldStatus":"Todo"}}]}'
# batch update — each record needs its id
curl -s -X PATCH "$BASE/api/table/tblXXX/record" -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"fieldKeyType":"id","typecast":true,"records":[{"id":"recXXX","fields":{"fldStatus":"Done"}}]}'
# query with filter (JSON must be url-encoded), pagination
FILTER='{"conjunction":"and","filterSet":[{"fieldId":"fldStatus","operator":"is","value":"Todo"}]}'
curl -s -G "$BASE/api/table/tblXXX/record" -H "$AUTH" \
--data-urlencode "filter=$FILTER" --data-urlencode "take=50" --data-urlencode "skip=0"
# change field options — MUST go through convert (PATCH strips options)
curl -s -X PUT "$BASE/api/table/tblXXX/field/fldXXX/convert" -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"type":"singleSelect","options":{"choices":[{"name":"Todo"},{"name":"Done"}]}}'
# automation: create → wire nodes → activate. Node JSON uses the SELF-BUILT
# contract: trigger "conditions" (not filter), "fieldIds" (not watchFieldIds),
# category "recordCondition" (not recordMatchesConditions) — full shapes in
# trigger-types.md / action-types.md
curl -s -X POST "$BASE/api/base/bseXXX/workflow" -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"name":"On Go click"}'
curl -s -X PUT "$BASE/api/base/bseXXX/workflow/wflXXX/node" -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"nodes":[{"type":"trigger","data":{"triggerType":"buttonClick","tableId":"tblXXX","fieldIds":["fldXXX"]}},{"type":"action","data":{"actionType":"script","code":"return triggerInput.record.fields"}}]}'
curl -s -X POST "$BASE/api/base/bseXXX/workflow/wflXXX/activate" -H "$AUTH"
```
Inside an automation **script action**, don't hardcode the token — call back
with the injected values:
```js
const res = await fetch(triggerInput._apiBaseUrl + "/api/table/tblB/record", {
method: "POST",
headers: { Authorization: "Bearer " + triggerInput._accessToken, "Content-Type": "application/json" },
body: JSON.stringify({ records: [{ fields: { fldLog: "done" } }] }),
});
```
## 4. Gotchas (read before debugging a silent no-op)
1. **`typecast:true` does NOT create select choices.** An invalid
singleSelect string resolves to null and the update becomes a **silent
no-op** (`mutationApplied=false`): HTTP 200, response echoes the OLD value,
`__last_modified_time` frozen. Always write existing choice values — check
`field get` / select-visibility first.
2. **400 "does not meet the current row conditions"** — value rejected by the
choice's `visibilityFilter`. Query
`GET /api/table/{tid}/select-visibility/{rid}/{fid}` for the allowed set.
3. **Field `options` only via the convert route** — plain `PATCH field` strips
them.
4. **`PUT …/node` is whole-graph replacement** — GET current nodes, modify,
PUT all of them back. Node edits take effect only after re-`activate`
(`hasDraft: true` = unactivated changes).
5. **Response-error shape**: non-2xx returns `{status, code, message}`;
`401` = bad/expired token, `403` = scope or base-role too low.
6. To see the exact request the CLI sends for any operation, run it with
`--debug` (prints method/url/body) — the fastest way to learn a route.
7. **Attachment fields accept URLs** (2026-10): pass `{"url":"https://…","name":"…"}` items
(or a URL string with `typecast:true` — note `typecast` is a BODY key) in
record create/update `fields`, and the backend downloads the file
(SSRF-guarded, size-capped) into attachment storage — response items come
back with real `token`/`presignedUrl`. A failed download rejects the whole
request (400); nothing is silently dropped. V1 (legacy) bases need
`typecast:true` for the string form, V2 bases accept both. The dedicated
`POST /api/table/{tid}/record/{rid}/{fid}/uploadAttachment` (multipart,
`file` or `fileUrl`) is the older single-record path — the plain record
routes now cover URL imports.
## 5. Full Route Inventory (this guide ≠ the whole API)
The backend exposes **~362 paths**; this guide documents the
agent-operational core (what the CLI wraps). The authoritative full spec is
the live OpenAPI JSON at `$BASE/docs-json` (browsable UI: `$BASE/docs`):
```bash
curl -s "$BASE/docs-json" | python3 -c "import json,sys; [print(m.upper(), p) for p,o in json.load(sys.stdin)['paths'].items() for m in o]"
```
### ⚠️ Swagger is a curated registry, NOT an auto-scan (audited 2026-09-23)
The doc is generated from an explicit `registerRoutes()` registry
(`packages/openapi/src/generate.schema.ts`), so any controller never
registered there is **invisible in /docs**. Cross-diffing every
`@Controller` route in the backend source against docs-json found gaps in
BOTH directions:
- **Real routes missing from swagger** (controller-verified, several
live-200): the whole **authority-matrix** family
(`/api/base/{bid}/authority-matrix*` — the 4-layer permission system, see
`docs/authority-matrix.md` in the app repo); **select-visibility**
(`GET /api/table/{tid}/select-visibility/{rid}/{fid}`); plugin runtime
(`/api/plugin/{pid}/runtime*`, `upload-bundle`, `bridge`, `plugin-proxy/*`,
`csp-origins`); ShareDB internals (`…/socket/doc-ids`,
`…/socket/snapshot-bulk`); oauth-server (`/api/oauth/authorize`,
`/decision`, `/access_token`); import status; admin ops
(performance-cache, heap-snapshot); `/health` + `/health/memory`
(backend origin only — the domain 404s them).
- **Doc entries with no controller on this CE instance** (~27: `/user-integrations*`,
`/unsubscribe/*`, `/billing/*`, `/admin/enterprise-license/*`…): registered
by the shared openapi package but served by the enterprise backend —
they 404 here.
So the completeness check is the **controller grep**, not docs-json.
Route groups NOT detailed in this guide (see docs-json for shapes):
`/api/auth/*` (session signin/signout, password/email flows, oauth),
`/api/user/*` (profile, lang, timezone, avatar), `/api/space/*` +
`/api/base/{bid}/node/*` (spaces & folder tree), `/api/share/*` +
`/api/base/{bid}/share` (public share views, 17 paths),
`/api/attachments/*` (upload/signature/notify), `/api/comment/{tid}`,
`/api/notifications/*`, `/api/mail-sender`, `/api/import/*` +
`/api/export/*` + `/api/base/import*` (CSV/Excel/Airtable),
`/api/table/{tid}/selection` (paste/copy ops), `/api/table/{tid}/undo-redo`,
`/api/trash/*`, `/api/template/*`, `/api/pin/*`, `/api/organization/*`,
`/api/oauth/*`, `/api/admin/*`, `/api/billing`, `/api/instance/usage`,
`/api/webhook/{bid}` (official teable webhooks — distinct from the
automation webhook route), plugin UI endpoints (`/api/plugin/*` panels,
chart, proxy), `/api/v2/integrity`, `/api/v1/ai-proxy`.
When you use one of these, verify the exact param shape before calling —
from docs-json, or from the backend
source:
`grep -rh "@Controller(\|@Get(\|@Post(\|@Patch(\|@Put(\|@Delete(" apps/nestjs-backend/src/features --include='*.controller.ts'`
in the inkreach-teable app repo. Do not guess key names (the automation
contract lesson applies instance-wide).
@@ -6,7 +6,7 @@ Complete command reference for `inkreach-teable`. Global options apply to all co
| Option | Description | | Option | Description |
|--------|-------------| |--------|-------------|
| `--endpoint <url>` | Backend URL (default `http://localhost:3003`) | | `--endpoint <url>` | Backend URL, e.g. `https://table.inkreach.cc` |
| `--token <pat>` | Personal Access Token | | `--token <pat>` | Personal Access Token |
| `--base-id <id>` | Default base id | | `--base-id <id>` | Default base id |
| `--json` | Output raw JSON (for agent parsing) | | `--json` | Output raw JSON (for agent parsing) |
@@ -27,6 +27,15 @@ inkreach-teable auth token delete <id>
- `auth token create` returns the full token **only once**. Default scopes: `automation|read,automation|create,automation|update,automation|delete,table|read,record|read,record|create,record|update`. - `auth token create` returns the full token **only once**. Default scopes: `automation|read,automation|create,automation|update,automation|delete,table|read,record|read,record|create,record|update`.
## space / base
```bash
inkreach-teable space list
inkreach-teable space create --name "<name>"
inkreach-teable base create --space-id <spaceId> --name "<name>"
inkreach-teable base delete <baseId> # DESTRUCTIVE: soft-deletes all tables in it
```
## base / table / field / record / view ## base / table / field / record / view
These resources use the official-teable-compatible API. Full guide: [tables-fields.md](tables-fields.md). These resources use the official-teable-compatible API. Full guide: [tables-fields.md](tables-fields.md).
@@ -44,7 +53,7 @@ inkreach-teable table update <tableId> --name <n>
inkreach-teable table delete <tableId> inkreach-teable table delete <tableId>
# field (get/update/visible-choices take <tableId> <fieldIdOrName> — id or display name, auto-resolved) # field (get/update/visible-choices take <tableId> <fieldIdOrName> — id or display name, auto-resolved)
inkreach-teable field list <tableId> [--with-options] inkreach-teable field list <tableId> # --table-id <id> also accepted [--with-options]
# --with-options: OPTIONS summary column + per-choice visibility conditions for select fields # --with-options: OPTIONS summary column + per-choice visibility conditions for select fields
inkreach-teable field get <tableId> <fieldIdOrName> inkreach-teable field get <tableId> <fieldIdOrName>
# human mode renders select choices with their visibilityFilter conditions; --json for raw # human mode renders select choices with their visibilityFilter conditions; --json for raw
@@ -56,12 +65,15 @@ inkreach-teable field visible-choices <tableId> <recordId> <fieldIdOrName>
inkreach-teable field delete <tableId> <fieldId> inkreach-teable field delete <tableId> <fieldId>
# record # record
inkreach-teable record get --table-id <tid> [--filter '<json>'] [--projection '<json>'] [--page-size <n>] [--page <n>] [--search <kw>] inkreach-teable record get --table-id <tid> [--filter '<json>'] [--projection '<json>'] [--page-size <n>|--take <n>] [--page <n>|--skip <n>] [--search <kw>] [--record-id <rid>]
inkreach-teable record get-one <recordId> inkreach-teable record list --table-id <tid> [...] # alias of record get
inkreach-teable record get-one <recordId> --table-id <tid>
inkreach-teable record create --table-id <tid> --fields '<json>' # single inkreach-teable record create --table-id <tid> --fields '<json>' # single
inkreach-teable record create --table-id <tid> --records '<json>' # batch inkreach-teable record create --table-id <tid> --records '<json>' # batch
inkreach-teable record update --table-id <tid> --records '<json>' inkreach-teable record update --table-id <tid> --records '<json>'
inkreach-teable record delete --table-id <tid> --record-ids <csv> # shared write flags: [--typecast] [--field-key-type <name|id|dbFieldName>]
# [--order '{"viewId":"viwX","anchorId":"recY","position":"before|after"}'] — insert/move in a manual-sort view
inkreach-teable record delete --table-id <tid> <recordId> [recordId ...] # or --record-ids <csv>
# view # view
inkreach-teable view list <tableId> inkreach-teable view list <tableId>
@@ -181,18 +193,6 @@ inkreach-teable subscription send <subscriptionId>
Record digest/instant email subscriptions. Options (`--filter`, `--send-mode`, Record digest/instant email subscriptions. Options (`--filter`, `--send-mode`,
`--schedule-*`, `--trigger-on-enter`, …) and semantics: [subscription.md](subscription.md). `--schedule-*`, `--trigger-on-enter`, …) and semantics: [subscription.md](subscription.md).
## sql
```bash
inkreach-teable sql '<SELECT ...>' [--base-id <id>]
```
Read-only. Must use `dbTableName`/`dbFieldName` (from `table get`/`field get`), double-quote identifiers, add `LIMIT 100`.
```bash
inkreach-teable sql 'SELECT "name" FROM "bseXXX"."tbl_abc" LIMIT 10'
```
## plugin ## plugin
Manage plugins and developer whitelist. Manage plugins and developer whitelist.
@@ -202,10 +202,14 @@ Manage plugins and developer whitelist.
inkreach-teable plugin list inkreach-teable plugin list
# Register a URL-served plugin (runtimeType=url; status published immediately) # Register a URL-served plugin (runtimeType=url; status published immediately)
# positions: "base"|"panel"|"view"|"dashboard"|"contextMenu" # positions: "base"|"panel"|"view"|"dashboard"|"contextMenu"|"route"
inkreach-teable plugin register --url <url> --name <name> [--positions '["panel"]'] [--logo <url>] [--description <d>] inkreach-teable plugin register --url <url> --name <name> [--positions '["panel"]'] [--logo <url>] [--description <d>]
# Bundle (upload) plugins use a different flow — see skill guides/tables-fields.md # Bundle (IIFE upload) plugins use a different flow — use the
# or api docs: create kebab-id plugin (developer whitelist) → upload → admin publish. # `inkreach-teable-plugin` skill instead: teable-plugin create/dev/deploy
# (scaffold → watch+hot-reload dev loop → multi-asset upload with chunks,
# source maps and css). Since 2026-10: POST /api/plugin (custom kebab-id,
# plugin_developer whitelist) and upload-bundle both accept PATs, and
# position "route" renders full-page apps at /base/:baseId/p/:pluginId.
# Developer whitelist (admin only) # Developer whitelist (admin only)
inkreach-teable plugin dev list inkreach-teable plugin dev list
@@ -217,6 +221,8 @@ inkreach-teable plugin install --plugin <pluginId> --name "My Plugin" --position
# ⚠️ For view/dashboard-position plugins, run `plugin config enable` BEFORE # ⚠️ For view/dashboard-position plugins, run `plugin config enable` BEFORE
# install, or the install API returns 403 RESTRICTED_RESOURCE # install, or the install API returns 403 RESTRICTED_RESOURCE
# 'Plugin is not enabled for this base'. # 'Plugin is not enabled for this base'.
# position "route" mounts a full-page app at /base/:baseId/p/:pluginId
# (no enable gate — the URL is the access point).
inkreach-teable plugin installs # list installs in current base inkreach-teable plugin installs # list installs in current base
inkreach-teable plugin uninstall --install <installId> inkreach-teable plugin uninstall --install <installId>
@@ -8,7 +8,8 @@ endpoint/ports for your own deployment.
| Service | URL / Port | Mode | Purpose | | Service | URL / Port | Mode | Purpose |
|---------|-----------|------|---------| |---------|-----------|------|---------|
| Backend API | `http://localhost:3003` | production (`NODE_ENV=production`) | REST API, automation engine, websocket | | Public domain | `https://table.inkreach.cc` | — | Frontend UI + `/api/*` and `/docs`+`/docs-json` proxied to backend (frontend next.config.js rewrites, since 2026-09-23) |
| Backend API | `https://table.inkreach.cc` | production (`NODE_ENV=production`) | REST API, automation engine, websocket, swagger `/docs` + `/docs-json` |
| Frontend | `http://localhost:3002` | production | End-user web UI (Next.js) | | Frontend | `http://localhost:3002` | production | End-user web UI (Next.js) |
| PostgreSQL | `localhost:5432` | — | Database (db: `teable`, user: `teable`) | | PostgreSQL | `localhost:5432` | — | Database (db: `teable`, user: `teable`) |
| Redis | `localhost:6379` | — | BullMQ queue for automation runs, distributed lock | | Redis | `localhost:6379` | — | BullMQ queue for automation runs, distributed lock |
@@ -21,7 +22,7 @@ endpoint/ports for your own deployment.
```bash ```bash
# Backend API up? # Backend API up?
curl -s -o /dev/null -w '%{http_code}' http://localhost:3003/api/auth/signin -X POST \ curl -s -o /dev/null -w '%{http_code}' https://table.inkreach.cc/api/auth/signin -X POST \
-H 'Content-Type: application/json' -d '{}' # expect 401 (endpoint exists, bad creds) -H 'Content-Type: application/json' -d '{}' # expect 401 (endpoint exists, bad creds)
# Frontend up? # Frontend up?
@@ -59,7 +60,7 @@ Note: `auth token list/create` may require a **session** (browser login) rather
### Configure the CLI ### Configure the CLI
```bash ```bash
inkreach-teable auth login --endpoint http://localhost:3003 --token <PAT> --base-id bseXXX inkreach-teable auth login --endpoint https://table.inkreach.cc --token <PAT> --base-id bseXXX
inkreach-teable auth status # verify inkreach-teable auth status # verify
``` ```
@@ -57,7 +57,7 @@ With select options: `"Status:sel:Todo,Doing,Done"`, `"Tags:mul:Bug,Feature,UI"`
### Update & Delete ### Update & Delete
```bash ```bash
inkreach-teable table update <tableId> --name "<newName>" [--description <d>] inkreach-teable table update <tableId> --name "<newName>" [--base-id <id>]
inkreach-teable table delete <tableId> inkreach-teable table delete <tableId>
``` ```
@@ -70,10 +70,11 @@ inkreach-teable table delete <tableId>
```bash ```bash
inkreach-teable table list [--base-id <id>] inkreach-teable table list [--base-id <id>]
inkreach-teable table get <tableId> inkreach-teable table get <tableId> [--base-id <id>]
``` ```
`table get` returns `dbTableName` — the internal SQL table name used in `sql` queries (e.g., `SELECT * FROM "bseXXX"."tbl_abc"`). `table get` returns `dbTableName` — the internal PG table name (useful for
maintainer-level psql inspection; there is NO SQL query API on this deployment).
--- ---
@@ -162,8 +163,8 @@ Single/multiple-select choices may carry a `visibilityFilter` making them visibl
inkreach-teable record get --table-id <tid> [--filter '<json>'] [--projection '<json>'] \ inkreach-teable record get --table-id <tid> [--filter '<json>'] [--projection '<json>'] \
[--page-size <n>] [--page <n>] [--search <kw>] [--page-size <n>] [--page <n>] [--search <kw>]
# Single record by ID # Single record by ID (table-scoped route)
inkreach-teable record get-one <recordId> inkreach-teable record get-one <recordId> --table-id <tid>
``` ```
| Flag | Description | | Flag | Description |
@@ -250,6 +251,25 @@ inkreach-teable record create --table-id tblXXX --fields '{"Assignee":{"id":"usr
inkreach-teable record create --table-id tblXXX --fields '{"Assignee":"Alice"}' --typecast inkreach-teable record create --table-id tblXXX --fields '{"Assignee":"Alice"}' --typecast
``` ```
**Attachment URL import** (2026-10): attachment fields accept http(s) URLs — the
backend downloads the file (SSRF-guarded, size-capped) and stores it as a real
attachment with thumbnail. Two forms:
```bash
# Object form — works without --typecast; name is optional (derived from the URL)
inkreach-teable record create --table-id tblXXX \
--fields '{"Files":[{"url":"https://example.com/f.png","name":"report.png"}]}'
# String form — needs --typecast; comma-separates URLs and existing actXXX ids
inkreach-teable record create --table-id tblXXX --typecast \
--fields '{"Files":"https://example.com/a.png,https://example.com/b.png"}'
```
Notes: only `http:`/`https:` URLs; URLs containing commas cannot be expressed in
the string form (use the object form); a failed download rejects the whole
request with 400 (nothing is silently dropped); V1 (legacy) bases require
`--typecast` for the string form, V2 bases accept both forms directly.
**`--records` format** (batch): `{"header":["Field1","Field2"],"records":[["val1","val2"],["val3","val4"]]}` **`--records` format** (batch): `{"header":["Field1","Field2"],"records":[["val1","val2"],["val3","val4"]]}`
### Update ### Update
@@ -260,7 +280,7 @@ inkreach-teable record update --table-id <tid> --records '<json>'
**`--records` format**: `[{"id":"recXXX","fields":{"FieldName":"newValue"}}]` — array of `{id, fields}` objects. Batch up to 2000. **`--records` format**: `[{"id":"recXXX","fields":{"FieldName":"newValue"}}]` — array of `{id, fields}` objects. Batch up to 2000.
**Record reordering**: pass `--order` with `viewId`, `anchorId`, `position`: **Record reordering**: pass `--order` with `viewId`, `anchorId`, `position` (works on both `record create` — insert at position — and `record update` — move existing records; only meaningful in a view without an explicit sort):
```bash ```bash
inkreach-teable record update --table-id tblXXX \ inkreach-teable record update --table-id tblXXX \
@@ -268,10 +288,17 @@ inkreach-teable record update --table-id tblXXX \
--order '{"viewId":"viwXXX","anchorId":"recYYY","position":"before"}' --order '{"viewId":"viwXXX","anchorId":"recYYY","position":"before"}'
``` ```
**`--field-key-type <name|id|dbFieldName>`** (default `name`): read `--fields`/`--records` keys as field ids instead of names — useful when field names contain characters that are awkward in JSON keys, or when you already have `fldXXX` ids from `field list --json`:
```bash
inkreach-teable record create --table-id tblXXX --field-key-type id \
--fields '{"fldXXXX":"hello"}'
```
### Delete ### Delete
```bash ```bash
inkreach-teable record delete --table-id <tid> --record-ids "recA,recB,recC" inkreach-teable record delete --table-id <tid> recA recB recC # or --record-ids "recA,recB,recC"
``` ```
Max 1000 IDs per call. Irreversible. Max 1000 IDs per call. Irreversible.
@@ -284,7 +311,7 @@ Max 1000 IDs per call. Irreversible.
```bash ```bash
inkreach-teable view list <tableId> inkreach-teable view list <tableId>
inkreach-teable view get <viewId> inkreach-teable view get <viewId> --table-id <tid>
``` ```
### Create ### Create
@@ -304,8 +331,8 @@ inkreach-teable view create --table-id <tid> --name "<name>" [--type <t>]
### Update (configure filters, sorts, grouping) ### Update (configure filters, sorts, grouping)
```bash ```bash
inkreach-teable view update <viewId> \ inkreach-teable view update <viewId> --table-id <tid> \
[--name "<n>"] \ [--name "<n>"] [--description "<d>"] \
[--filter '<json>'] \ [--filter '<json>'] \
[--sort '<json>'] \ [--sort '<json>'] \
[--group '<json>'] \ [--group '<json>'] \
@@ -314,7 +341,7 @@ inkreach-teable view update <viewId> \
**Filters**: `{"conjunction":"and","filterSet":[{"fieldId":"fldXXX","operator":"is","value":"Done"}]}` **Filters**: `{"conjunction":"and","filterSet":[{"fieldId":"fldXXX","operator":"is","value":"Done"}]}`
**Sorts**: `[{"fieldId":"fldXXX","order":"asc"}]` **Sorts**: `[{"fieldId":"fldXXX","order":"asc"}]` (the CLI wraps it into `{sortObjs:[...]}` for the API)
**Group**: `[{"fieldId":"fldXXX","order":"asc"}]` **Group**: `[{"fieldId":"fldXXX","order":"asc"}]`
@@ -323,7 +350,7 @@ inkreach-teable view update <viewId> \
### Delete ### Delete
```bash ```bash
inkreach-teable view delete <viewId> inkreach-teable view delete <viewId> --table-id <tid>
``` ```
Deleting a view does NOT delete the table or its data — only the view configuration. Deleting a view does NOT delete the table or its data — only the view configuration.
@@ -377,7 +404,7 @@ inkreach-teable preset delete <presetId> --table-id tblXxx
| `singleSelect` | `"Option Name"` | (string) | | `singleSelect` | `"Option Name"` | (string) |
| `multipleSelect` | `["Tag A","Tag B"]` | (array of strings) | | `multipleSelect` | `["Tag A","Tag B"]` | (array of strings) |
| `rating` | `3` | Integer 1..max | | `rating` | `3` | Integer 1..max |
| `attachment` | `[{"name":"f.png","token":"xxx"}]` | Always array of objects; update **replaces all** | | `attachment` | `[{"name":"f.png","token":"xxx"}]` | Always array of objects; update **replaces all**. Also accepts `[{"url":"https://…","name":"f.png"}]` (server downloads the file) and, with `--typecast`, a plain URL string — see attachment URL import below |
| `user` | `{"id":"usrXXX","title":"Alice"}` | With `--typecast`: just `"Alice"` or `"alice@example.com"` | | `user` | `{"id":"usrXXX","title":"Alice"}` | With `--typecast`: just `"Alice"` or `"alice@example.com"` |
| `link` | `[{"id":"recXXX"}]` | With `--typecast`: just `"Linked Record Name"` | | `link` | `[{"id":"recXXX"}]` | With `--typecast`: just `"Linked Record Name"` |
| `formula` / `rollup` / `lookup` | Read-only | Cannot be set via record create/update | | `formula` / `rollup` / `lookup` | Read-only | Cannot be set via record create/update |
@@ -403,4 +430,5 @@ inkreach-teable preset delete <presetId> --table-id tblXxx
| View data in kanban board | `view create --type kanban` then `view update --group` | Creating special columns | | View data in kanban board | `view create --type kanban` then `view update --group` | Creating special columns |
| Set datetime in a date field | `"2025-06-15T14:30:00.000Z"` | `"2025-06-15 14:30"` (non-ISO; may be rejected) | | Set datetime in a date field | `"2025-06-15T14:30:00.000Z"` | `"2025-06-15 14:30"` (non-ISO; may be rejected) |
| Update attachments | Always pass the full array (replaces all) | Trying to add/remove individual attachments | | Update attachments | Always pass the full array (replaces all) | Trying to add/remove individual attachments |
| Attach a file by URL | `record create --fields '{"Files":[{"url":"https://…/f.png"}]}'` (object form, no typecast needed) or `--typecast --fields '{"Files":"https://…/f.png"}'` | Uploading via the attachments signature/notify endpoints first (only needed for local files) |
| Batch insert 500 records | One `record create --records` call | 500 separate `record create` calls | | Batch insert 500 records | One `record create --records` call | 500 separate `record create` calls |
@@ -10,9 +10,9 @@
set -e set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Resolve the repo root: skill lives at <root>/.agents/skills/inkreach-teable/ # Resolve the repo root: skill lives at <root>/skills/inkreach-teable/scripts/
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
SKILL_DIR="$REPO_ROOT/.agents/skills/inkreach-teable" SKILL_DIR="$REPO_ROOT/skills/inkreach-teable"
echo "=== inkreach-teable CLI installation ===" echo "=== inkreach-teable CLI installation ==="
@@ -62,13 +62,17 @@ exec node "$REPO_ROOT/dist/bin.js" "\$@"
WRAPPER WRAPPER
chmod +x "$HOME/.local/bin/inkreach-teable" chmod +x "$HOME/.local/bin/inkreach-teable"
# Install the skill at user scope so it works in ANY workspace, including # Install every skill in this repo at user scope so they work in ANY workspace,
# checkouts of the inkreach-teable application repo (which no longer bundles it). # including checkouts of the inkreach-teable application repo (which no longer
if [ -d "$SKILL_DIR" ]; then # bundles them): inkreach-teable + inkreach-teable-plugin.
if [ -d "$REPO_ROOT/skills" ]; then
mkdir -p "$HOME/.zcode/skills" mkdir -p "$HOME/.zcode/skills"
rm -rf "$HOME/.zcode/skills/inkreach-teable" for skill_dir in "$REPO_ROOT/skills"/*/; do
cp -r "$SKILL_DIR" "$HOME/.zcode/skills/inkreach-teable" skill_name="$(basename "$skill_dir")"
echo "✓ skill installed to ~/.zcode/skills/inkreach-teable (user scope)" rm -rf "$HOME/.zcode/skills/$skill_name"
cp -r "$skill_dir" "$HOME/.zcode/skills/$skill_name"
echo "✓ skill installed: $skill_name (~/.zcode/skills, user scope)"
done
fi fi
# Verify # Verify
+1 -2
View File
@@ -10,7 +10,7 @@ import { pluginCommand } from './commands/plugin';
import { authCommand } from './commands/auth'; import { authCommand } from './commands/auth';
import { workflowCommand, triggerCommand, actionCommand } from './commands/workflow'; import { workflowCommand, triggerCommand, actionCommand } from './commands/workflow';
import { baseResourceCommands } from './commands/resources'; import { baseResourceCommands } from './commands/resources';
import { secretCommand, runsCommand, fireCommand, sqlCommand } from './commands/extras'; import { secretCommand, runsCommand, fireCommand } from './commands/extras';
import { subscriptionCommand } from './commands/subscription'; import { subscriptionCommand } from './commands/subscription';
const program = new Command(); const program = new Command();
@@ -42,7 +42,6 @@ program.addCommand(subscriptionCommand());
program.addCommand(secretCommand()); program.addCommand(secretCommand());
program.addCommand(runsCommand()); program.addCommand(runsCommand());
program.addCommand(fireCommand()); program.addCommand(fireCommand());
program.addCommand(sqlCommand());
program.parseAsync(process.argv).catch((err) => { program.parseAsync(process.argv).catch((err) => {
process.stderr.write(`Fatal: ${err instanceof Error ? err.message : String(err)}\n`); process.stderr.write(`Fatal: ${err instanceof Error ? err.message : String(err)}\n`);
-47
View File
@@ -2,7 +2,6 @@
* `inkreach-teable secret` — manage workflow automation secrets. * `inkreach-teable secret` — manage workflow automation secrets.
* `inkreach-teable runs` — execution history. * `inkreach-teable runs` — execution history.
* `inkreach-teable fire` — manual trigger testing (button-click / webhook). * `inkreach-teable fire` — manual trigger testing (button-click / webhook).
* `inkreach-teable sql` — read-only SQL queries.
*/ */
import { Command } from 'commander'; import { Command } from 'commander';
import { run, requireBaseId } from '../lib/ctx'; import { run, requireBaseId } from '../lib/ctx';
@@ -282,49 +281,3 @@ export function fireCommand(): Command {
return cmd; return cmd;
} }
// ===================== sql =====================
export function sqlCommand(): Command {
const cmd = new Command('sql');
cmd.description('Execute a read-only SQL query (SELECT only)');
cmd.option('--base-id <id>', 'Base id (overrides default)');
cmd.argument('<sql>', 'SQL SELECT statement');
cmd.action(
run(async (ctx, args, o) => {
const sql = args[0] as string;
const opts = o as { baseId?: string };
const baseId = requireBaseId(ctx, opts.baseId);
const rows = (await ctx.client.sql(baseId, sql)) as unknown[];
if (ctx.json) {
printJson(rows);
} else {
if (!rows.length) {
process.stdout.write('No rows returned.\n');
return;
}
const first = rows[0];
if (typeof first === 'object' && first !== null && !Array.isArray(first)) {
const keys = Object.keys(first as Record<string, unknown>);
printTable(
keys.map((k) => ({ key: k, header: k })),
rows.map((r) => {
const obj = r as Record<string, unknown>;
const row: Record<string, unknown> = {};
for (const k of keys) {
row[k] = truncate(obj[k], 40);
}
return row;
})
);
process.stdout.write(`\n${rows.length} row(s).\n`);
} else {
rows.forEach((r) => process.stdout.write(JSON.stringify(r) + '\n'));
}
}
})
);
return cmd;
}
+302 -40
View File
@@ -20,6 +20,55 @@ import {
} from '../lib/field-normalize'; } from '../lib/field-normalize';
import { formatFieldDetail, summarizeFieldOptions, choiceRows } from '../lib/field-display'; import { formatFieldDetail, summarizeFieldOptions, choiceRows } from '../lib/field-display';
const FIELD_KEY_TYPES = ['name', 'id', 'dbFieldName'] as const;
type FieldKeyTypeCli = (typeof FIELD_KEY_TYPES)[number];
/** Validate --field-key-type (backend body key `fieldKeyType`, default "name"). */
function parseFieldKeyType(raw: string | undefined): FieldKeyTypeCli | undefined {
if (raw === undefined) return undefined;
if (!(FIELD_KEY_TYPES as readonly string[]).includes(raw)) {
throw Object.assign(
new Error(`--field-key-type must be one of: ${FIELD_KEY_TYPES.join(', ')}`),
{ code: 'BAD_FIELD_KEY_TYPE' }
);
}
return raw as FieldKeyTypeCli;
}
type RecordOrderRo = { viewId: string; anchorId: string; position: 'before' | 'after' };
/**
* Validate --order (backend body key `order` on record create/update):
* place the written records before/after an anchor record in a
* manually-sorted view.
*/
function parseRecordOrder(raw: string): RecordOrderRo {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw Object.assign(
new Error('--order must be JSON: {"viewId":"viwXXX","anchorId":"recYYY","position":"before"}'),
{ code: 'BAD_JSON' }
);
}
const obj = parsed as Record<string, unknown>;
const { viewId, anchorId, position } = obj ?? {};
if (
typeof viewId !== 'string' ||
typeof anchorId !== 'string' ||
(position !== 'before' && position !== 'after')
) {
throw Object.assign(
new Error(
'--order needs string viewId + anchorId and position "before"|"after", e.g. {"viewId":"viwXXX","anchorId":"recYYY","position":"before"}'
),
{ code: 'BAD_ORDER' }
);
}
return { viewId, anchorId, position };
}
/** /**
* Fetch a field by ID or display name. A direct ID hit needs no extra call; * Fetch a field by ID or display name. A direct ID hit needs no extra call;
* a 400/404 miss (the API rejects non-ID paths) falls back to resolving the * a 400/404 miss (the API rejects non-ID paths) falls back to resolving the
@@ -50,7 +99,7 @@ async function fieldNameMap(client: Client, tableId: string): Promise<Record<str
} }
export function baseResourceCommands(): Command[] { export function baseResourceCommands(): Command[] {
return [baseCmd(), tableCmd(), fieldCmd(), recordCmd(), viewCmd(), presetCmd()]; return [baseCmd(), spaceCmd(), tableCmd(), fieldCmd(), recordCmd(), viewCmd(), presetCmd()];
} }
// ===================== base ===================== // ===================== base =====================
@@ -82,6 +131,35 @@ function baseCmd(): Command {
}) })
); );
cmd
.command('create')
.description('Create a base in a space')
.requiredOption('--space-id <id>', 'Space id (see `space list`)')
.requiredOption('--name <name>', 'New base name')
.action(
run(async (ctx, _a, o) => {
const opts = o as { spaceId: string; name: string };
const base = await ctx.client.createBase(opts.spaceId, opts.name);
if (ctx.json) printJson(base);
else {
const b = base as Record<string, unknown>;
printKv([['id', b.id], ['name', b.name]]);
}
})
);
cmd
.command('delete <baseId>')
.description('Soft-delete a base (DESTRUCTIVE: hides all its tables)')
.action(
run(async (ctx, args) => {
const baseId = args[0] as string;
await ctx.client.deleteBase(baseId);
if (ctx.json) printJson({ ok: true, id: baseId });
else process.stdout.write(`✓ Base ${baseId} deleted (soft)\n`);
})
);
cmd cmd
.command('get <baseId>') .command('get <baseId>')
.description('Get base details') .description('Get base details')
@@ -100,6 +178,54 @@ function baseCmd(): Command {
return cmd; return cmd;
} }
// ===================== space =====================
function spaceCmd(): Command {
const cmd = new Command('space');
cmd.description('Manage spaces (top-level containers for bases)');
cmd
.command('list')
.description('List spaces you can access')
.action(
run(async (ctx) => {
const list = (await ctx.client.listSpaces()) as Array<Record<string, unknown>>;
if (ctx.json) printJson(list);
else {
if (!list.length) {
process.stdout.write('No spaces found.\n');
return;
}
printTable(
[
{ key: 'id', header: 'ID' },
{ key: 'name', header: 'NAME' },
],
list.map((sp) => ({ id: truncate(sp.id), name: truncate(sp.name, 40) }))
);
}
})
);
cmd
.command('create')
.description('Create a space')
.requiredOption('--name <name>', 'New space name')
.action(
run(async (ctx, _a, o) => {
const opts = o as { name: string };
const sp = await ctx.client.createSpace(opts.name);
if (ctx.json) printJson(sp);
else {
const s2 = sp as Record<string, unknown>;
printKv([['id', s2.id], ['name', s2.name]]);
}
})
);
return cmd;
}
// ===================== table ===================== // ===================== table =====================
function tableCmd(): Command { function tableCmd(): Command {
@@ -140,10 +266,12 @@ function tableCmd(): Command {
cmd cmd
.command('get <tableId>') .command('get <tableId>')
.description('Get table details') .description('Get table details')
.option('--base-id <id>', 'Base id (overrides default)')
.action( .action(
run(async (ctx, args) => { run(async (ctx, args, o) => {
const tableId = args[0] as string; const tableId = args[0] as string;
const table = await ctx.client.getTable(tableId); const opts = o as { baseId?: string };
const table = await ctx.client.getTable(tableId, requireBaseId(ctx, opts.baseId));
if (ctx.json) printJson(table); if (ctx.json) printJson(table);
else { else {
const t = table as Record<string, unknown>; const t = table as Record<string, unknown>;
@@ -234,16 +362,18 @@ function fieldCmd(): Command {
cmd.description('Manage fields (columns) in a table'); cmd.description('Manage fields (columns) in a table');
cmd cmd
.command('list <tableId>') .command('list [tableId]')
.description('List fields in a table') .description('List fields in a table')
.option('--table-id <id>', 'Table id (alternative to the positional argument)')
.option( .option(
'--with-options', '--with-options',
'Add an OPTIONS summary column and expand select choices with their visibility conditions' 'Add an OPTIONS summary column and expand select choices with their visibility conditions'
) )
.action( .action(
run(async (ctx, args, o) => { run(async (ctx, args, o) => {
const opts = o as { withOptions?: boolean }; const opts = o as { withOptions?: boolean; tableId?: string };
const tableId = args[0] as string; const tableId = opts.tableId || (args[0] as string);
if (!tableId) throw new Error('Pass a tableId (positional or --table-id)');
const list = (await ctx.client.listFields(tableId)) as Array<Record<string, unknown>>; const list = (await ctx.client.listFields(tableId)) as Array<Record<string, unknown>>;
if (ctx.json) printJson(list); if (ctx.json) printJson(list);
else { else {
@@ -303,13 +433,23 @@ function fieldCmd(): Command {
); );
cmd cmd
.command('get <tableId> <fieldIdOrName>') .command('get [tableId] [fieldIdOrName]')
.description( .description(
'Get field details by id or name (full options; select choices rendered with visibility conditions)' 'Get field details by id or name (full options; select choices rendered with visibility conditions)'
) )
.option('--table-id <id>', 'Table id (alternative to the positional argument)')
.action( .action(
run(async (ctx, args) => { run(async (ctx, args, o) => {
const [tableId, fieldIdOrName] = args as [string, string]; const opts = o as { tableId?: string };
let tableId = opts.tableId || (args[0] as string);
let fieldIdOrName = args[1] as string;
// `field get <fieldIdOrName> --table-id X` — one positional + flag
if (!fieldIdOrName) {
fieldIdOrName = args[0] as string;
tableId = opts.tableId || '';
}
if (!tableId || !fieldIdOrName)
throw new Error('Usage: field get <tableId> <fieldIdOrName> (or --table-id <id> <fieldIdOrName>)');
const field = await getFieldFlexible(ctx.client, tableId, fieldIdOrName); const field = await getFieldFlexible(ctx.client, tableId, fieldIdOrName);
if (ctx.json) printJson(field); if (ctx.json) printJson(field);
else { else {
@@ -469,12 +609,16 @@ function recordCmd(): Command {
cmd cmd
.command('get') .command('get')
.description('Query records with filters, pagination, and search') .description("Query records with filters, pagination, and search (alias: 'list')")
.alias('list')
.requiredOption('--table-id <id>', 'Table id') .requiredOption('--table-id <id>', 'Table id')
.option('--record-id <id>', 'Get a single record by id (alternative to get-one)')
.option('--filter <json>', 'Filter conditions JSON') .option('--filter <json>', 'Filter conditions JSON')
.option('--projection <json>', 'Field projection JSON array') .option('--projection <json>', 'Field projection JSON array')
.option('--page-size <n>', 'Records per page') .option('--page-size <n>', 'Records per page')
.option('--take <n>', 'Alias of --page-size')
.option('--page <n>', 'Page number (1-based)') .option('--page <n>', 'Page number (1-based)')
.option('--skip <n>', 'Records to skip (raw offset; overrides --page)')
.option('--search <kw>', 'Search keyword') .option('--search <kw>', 'Search keyword')
.option('--base-id <id>', 'Base id (overrides default)') .option('--base-id <id>', 'Base id (overrides default)')
.action( .action(
@@ -484,10 +628,22 @@ function recordCmd(): Command {
filter?: string; filter?: string;
projection?: string; projection?: string;
pageSize?: string; pageSize?: string;
take?: string;
page?: string; page?: string;
skip?: string;
search?: string; search?: string;
baseId?: string; baseId?: string;
recordId?: string;
}; };
if (opts.recordId) {
const rec = await ctx.client.getRecord(opts.tableId, opts.recordId);
if (ctx.json) printJson(rec);
else {
const r = rec as { id?: string; fields?: Record<string, unknown> };
printKv([['id', r.id], ...Object.entries(r.fields || {})]);
}
return;
}
// validate baseId is present (even though getRecords only needs tableId, // validate baseId is present (even though getRecords only needs tableId,
// we follow the convention that base-scoped commands require baseId config) // we follow the convention that base-scoped commands require baseId config)
requireBaseId(ctx, opts.baseId); requireBaseId(ctx, opts.baseId);
@@ -498,9 +654,12 @@ function recordCmd(): Command {
if (opts.search) query.search = opts.search; if (opts.search) query.search = opts.search;
const page = opts.page ? parseInt(opts.page, 10) : undefined; const page = opts.page ? parseInt(opts.page, 10) : undefined;
const pageSize = opts.pageSize ? parseInt(opts.pageSize, 10) : undefined; const pageSize = opts.pageSize || opts.take
? parseInt((opts.pageSize || opts.take) as string, 10)
: undefined;
if (pageSize) query.take = pageSize; if (pageSize) query.take = pageSize;
if (page) { if (opts.skip) query.skip = parseInt(opts.skip, 10);
else if (page) {
const take = pageSize || 100; const take = pageSize || 100;
query.skip = (page - 1) * take; query.skip = (page - 1) * take;
if (!pageSize) query.take = take; if (!pageSize) query.take = take;
@@ -543,10 +702,12 @@ function recordCmd(): Command {
cmd cmd
.command('get-one <recordId>') .command('get-one <recordId>')
.description('Get a single record by id') .description('Get a single record by id')
.requiredOption('--table-id <id>', 'Table id (record routes are table-scoped)')
.action( .action(
run(async (ctx, args) => { run(async (ctx, args, o) => {
const recordId = args[0] as string; const recordId = args[0] as string;
const record = await ctx.client.getRecord(recordId); const opts = o as { tableId: string };
const record = await ctx.client.getRecord(opts.tableId, recordId);
if (ctx.json) printJson(record); if (ctx.json) printJson(record);
else { else {
const rec = record as { id?: string; fields?: Record<string, unknown> }; const rec = record as { id?: string; fields?: Record<string, unknown> };
@@ -565,19 +726,50 @@ function recordCmd(): Command {
.requiredOption('--table-id <id>', 'Table id') .requiredOption('--table-id <id>', 'Table id')
.option('--fields <json>', 'Fields object JSON, e.g. \'{"Title":"hello"}\'') .option('--fields <json>', 'Fields object JSON, e.g. \'{"Title":"hello"}\'')
.option('--records <json>', 'Records array JSON for bulk, e.g. \'[{"fields":{"Title":"a"}}]\'') .option('--records <json>', 'Records array JSON for bulk, e.g. \'[{"fields":{"Title":"a"}}]\'')
.option(
'--typecast',
'Send typecast=true (accepts human-readable values; attachment fields accept http(s) urls)'
)
.option(
'--field-key-type <type>',
'Keys in --fields/--records are field ids ("id"), names ("name", default) or dbFieldName'
)
.option(
'--order <json>',
'Insert position in a manually-sorted view: {"viewId":"viwXXX","anchorId":"recYYY","position":"before|after"}'
)
.action( .action(
run(async (ctx, _args, o) => { run(async (ctx, _args, o) => {
const opts = o as { tableId: string; fields?: string; records?: string }; const opts = o as {
let body: unknown; tableId: string;
fields?: string;
records?: string;
typecast?: boolean;
fieldKeyType?: string;
order?: string;
};
let body: {
typecast?: boolean;
fieldKeyType?: FieldKeyTypeCli;
order?: RecordOrderRo;
records: unknown;
};
if (opts.records) { if (opts.records) {
body = { records: JSON.parse(opts.records) }; body = { records: JSON.parse(opts.records) };
} else if (opts.fields) { } else if (opts.fields) {
body = { fields: JSON.parse(opts.fields) }; // The create API only accepts {records:[...]}; wrap the single-record
// convenience form so both spellings work.
body = { records: [{ fields: JSON.parse(opts.fields) }] };
} else { } else {
throw Object.assign(new Error('Provide --fields or --records'), { throw Object.assign(new Error('Provide --fields or --records'), {
code: 'MISSING_FIELDS', code: 'MISSING_FIELDS',
}); });
} }
// typecast/fieldKeyType/order are BODY keys of createRecordsRoSchema (not query params)
if (opts.typecast) body.typecast = true;
const fieldKeyType = parseFieldKeyType(opts.fieldKeyType);
if (fieldKeyType) body.fieldKeyType = fieldKeyType;
if (opts.order !== undefined) body.order = parseRecordOrder(opts.order);
const result = await ctx.client.createRecord(opts.tableId, body); const result = await ctx.client.createRecord(opts.tableId, body);
if (ctx.json) printJson(result); if (ctx.json) printJson(result);
else process.stdout.write(`✓ Record(s) created\n`); else process.stdout.write(`✓ Record(s) created\n`);
@@ -592,10 +784,38 @@ function recordCmd(): Command {
'--records <json>', '--records <json>',
'Records JSON, e.g. \'[{"id":"recX","fields":{"Title":"b"}}]\'' 'Records JSON, e.g. \'[{"id":"recX","fields":{"Title":"b"}}]\''
) )
.option(
'--typecast',
'Send typecast=true (accepts human-readable values; attachment fields accept http(s) urls)'
)
.option(
'--field-key-type <type>',
'Keys in --records fields are field ids ("id"), names ("name", default) or dbFieldName'
)
.option(
'--order <json>',
'Move the updated record(s) before/after an anchor in a manually-sorted view: {"viewId":"viwXXX","anchorId":"recYYY","position":"before|after"}'
)
.action( .action(
run(async (ctx, _args, o) => { run(async (ctx, _args, o) => {
const opts = o as { tableId: string; records: string }; const opts = o as {
const body = { records: JSON.parse(opts.records) }; tableId: string;
records: string;
typecast?: boolean;
fieldKeyType?: string;
order?: string;
};
const body: {
typecast?: boolean;
fieldKeyType?: FieldKeyTypeCli;
order?: RecordOrderRo;
records: unknown;
} = { records: JSON.parse(opts.records) };
// typecast/fieldKeyType/order are BODY keys of updateRecordsRoSchema (not query params)
if (opts.typecast) body.typecast = true;
const fieldKeyType = parseFieldKeyType(opts.fieldKeyType);
if (fieldKeyType) body.fieldKeyType = fieldKeyType;
if (opts.order !== undefined) body.order = parseRecordOrder(opts.order);
const result = await ctx.client.updateRecord(opts.tableId, body); const result = await ctx.client.updateRecord(opts.tableId, body);
if (ctx.json) printJson(result); if (ctx.json) printJson(result);
else process.stdout.write(`✓ Records updated\n`); else process.stdout.write(`✓ Records updated\n`);
@@ -603,17 +823,33 @@ function recordCmd(): Command {
); );
cmd cmd
.command('delete') .command('delete [recordIds...]')
.description('Batch delete records by ids') .description('Batch delete records by ids')
.requiredOption('--table-id <id>', 'Table id') .requiredOption('--table-id <id>', 'Table id')
.requiredOption('--record-ids <csv>', 'Record ids (comma-separated)') .option('--record-ids <csv>', 'Record ids (comma-separated; or pass ids as arguments)')
.action( .action(
run(async (ctx, _args, o) => { run(async (ctx, args, o) => {
const opts = o as { tableId: string; recordIds: string }; const opts = o as { tableId: string; recordIds?: string };
const ids = opts.recordIds // With variadic args commander appends the options bag to positionals —
.split(',') // flatten and keep only actual id strings.
.map((s) => s.trim()) const ids = [
.filter(Boolean); ...new Set(
[
...(opts.recordIds
? opts.recordIds
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: []),
...(args as unknown[]).flat().filter((x): x is string => typeof x === 'string'),
].flat()
),
];
if (!ids.length) {
throw Object.assign(new Error('Provide record ids (--record-ids or arguments)'), {
code: 'MISSING_RECORD_IDS',
});
}
const result = await ctx.client.deleteRecords(opts.tableId, ids); const result = await ctx.client.deleteRecords(opts.tableId, ids);
if (ctx.json) printJson(result); if (ctx.json) printJson(result);
else process.stdout.write(`✓ ${ids.length} record(s) deleted\n`); else process.stdout.write(`✓ ${ids.length} record(s) deleted\n`);
@@ -630,11 +866,13 @@ function viewCmd(): Command {
cmd.description('Manage views in a table'); cmd.description('Manage views in a table');
cmd cmd
.command('list <tableId>') .command('list [tableId]')
.description('List views in a table') .description('List views in a table')
.option('--table-id <id>', 'Table id (alternative to the positional argument)')
.action( .action(
run(async (ctx, args) => { run(async (ctx, args, o) => {
const tableId = args[0] as string; const tableId = ((o as { tableId?: string }).tableId || args[0]) as string;
if (!tableId) throw new Error('Pass a tableId (positional or --table-id)');
const list = (await ctx.client.listViews(tableId)) as Array<Record<string, unknown>>; const list = (await ctx.client.listViews(tableId)) as Array<Record<string, unknown>>;
if (ctx.json) printJson(list); if (ctx.json) printJson(list);
else { else {
@@ -661,10 +899,12 @@ function viewCmd(): Command {
cmd cmd
.command('get <viewId>') .command('get <viewId>')
.description('Get view details') .description('Get view details')
.requiredOption('--table-id <id>', 'Table id (view routes are table-scoped)')
.action( .action(
run(async (ctx, args) => { run(async (ctx, args, o) => {
const viewId = args[0] as string; const viewId = args[0] as string;
const view = await ctx.client.getView(viewId); const opts = o as { tableId: string };
const view = await ctx.client.getView(opts.tableId, viewId);
if (ctx.json) printJson(view); if (ctx.json) printJson(view);
else { else {
const v = view as Record<string, unknown>; const v = view as Record<string, unknown>;
@@ -698,15 +938,34 @@ function viewCmd(): Command {
cmd cmd
.command('update <viewId>') .command('update <viewId>')
.description('Update a view (rename)') .description('Update a view (name, description, filter, sort, group, column-meta)')
.requiredOption('--table-id <id>', 'Table id (view routes are table-scoped)')
.option('--name <name>', 'New view name') .option('--name <name>', 'New view name')
.option('--description <d>', 'New description')
.option('--filter <json>', 'Filter JSON, e.g. \'{"conjunction":"and","filterSet":[...]}\'')
.option('--sort <json>', 'Sort JSON, e.g. \'[{"fieldId":"fldX","order":"asc"}]\'')
.option('--group <json>', 'Group JSON, e.g. \'[{"fieldId":"fldX","order":"asc"}]\'')
.option('--column-meta <json>', 'Column meta JSON array of {fieldId, columnMeta}')
.action( .action(
run(async (ctx, args, o) => { run(async (ctx, args, o) => {
const viewId = args[0] as string; const viewId = args[0] as string;
const opts = o as { name?: string }; const opts = o as {
const body: Record<string, unknown> = {}; tableId: string;
name?: string;
description?: string;
filter?: string;
sort?: string;
group?: string;
columnMeta?: string;
};
const body: Parameters<typeof ctx.client.updateView>[2] = {};
if (opts.name) body.name = opts.name; if (opts.name) body.name = opts.name;
const view = await ctx.client.updateView(viewId, body); if (opts.description) body.description = opts.description;
if (opts.filter) body.filter = JSON.parse(opts.filter);
if (opts.sort) body.sort = JSON.parse(opts.sort);
if (opts.group) body.group = JSON.parse(opts.group);
if (opts.columnMeta) body.columnMeta = JSON.parse(opts.columnMeta);
const view = await ctx.client.updateView(opts.tableId, viewId, body);
if (ctx.json) printJson(view); if (ctx.json) printJson(view);
else process.stdout.write(`✓ View ${viewId} updated\n`); else process.stdout.write(`✓ View ${viewId} updated\n`);
}) })
@@ -715,10 +974,12 @@ function viewCmd(): Command {
cmd cmd
.command('delete <viewId>') .command('delete <viewId>')
.description('Delete a view') .description('Delete a view')
.requiredOption('--table-id <id>', 'Table id (view routes are table-scoped)')
.action( .action(
run(async (ctx, args) => { run(async (ctx, args, o) => {
const viewId = args[0] as string; const viewId = args[0] as string;
await ctx.client.deleteView(viewId); const opts = o as { tableId: string };
await ctx.client.deleteView(opts.tableId, viewId);
if (ctx.json) printJson({ ok: true, id: viewId }); if (ctx.json) printJson({ ok: true, id: viewId });
else process.stdout.write(`✓ View ${viewId} deleted\n`); else process.stdout.write(`✓ View ${viewId} deleted\n`);
}) })
@@ -775,12 +1036,13 @@ function presetCmd(): Command {
); );
cmd cmd
.command('list <tableId>') .command('list [tableId]')
.description('List view presets of a table (pass --view-id to scope to one view)') .description('List view presets of a table (pass --view-id to scope to one view)')
.option('--table-id <id>', 'Table id (alternative to the positional argument)')
.option('--view-id <id>', 'Only presets bound to this view') .option('--view-id <id>', 'Only presets bound to this view')
.action( .action(
run(async (ctx, args, o) => { run(async (ctx, args, o) => {
const tableId = args[0] as string; const tableId = ((o as { tableId?: string }).tableId || args[0]) as string;
const opts = o as unknown as IPresetOptions; const opts = o as unknown as IPresetOptions;
const list = (await ctx.client.listViewPresets(tableId, opts.viewId)) as Array< const list = (await ctx.client.listViewPresets(tableId, opts.viewId)) as Array<
Record<string, unknown> Record<string, unknown>
+74 -22
View File
@@ -54,19 +54,36 @@ export class Client {
getBase(baseId: string) { getBase(baseId: string) {
return this.call<unknown>('GET', `/api/base/${baseId}`); return this.call<unknown>('GET', `/api/base/${baseId}`);
} }
createBase(spaceId: string, name: string) {
return this.call<unknown>('POST', '/api/base', { spaceId, name });
}
deleteBase(baseId: string) {
return this.call<{ id: string }>('DELETE', `/api/base/${baseId}`);
}
listSpaces() {
return this.call<unknown[]>('GET', '/api/space');
}
createSpace(name: string) {
return this.call<unknown>('POST', '/api/space', { name });
}
// ---- table ---- // ---- table ----
listTables(baseId: string) { listTables(baseId: string) {
return this.call<unknown[]>('GET', `/api/base/${baseId}/table`); return this.call<unknown[]>('GET', `/api/base/${baseId}/table`);
} }
getTable(tableId: string) { getTable(tableId: string, baseId?: string) {
return this.call<unknown>('GET', `/api/table/${tableId}`); // Route is base-nested: GET /api/base/:baseId/table/:tableId (a bare
// /api/table/:tableId does not exist and 404s).
const base = baseId ?? this.cfg.baseId;
return this.call<unknown>('GET', `/api/base/${base}/table/${tableId}`);
} }
createTable(baseId: string, body: unknown) { createTable(baseId: string, body: unknown) {
return this.call<unknown>('POST', `/api/base/${baseId}/table`, body); return this.call<unknown>('POST', `/api/base/${baseId}/table`, body);
} }
updateTable(tableId: string, body: unknown) { updateTable(tableId: string, body: { name: string }, baseId?: string) {
return this.call<unknown>('PUT', `/api/table/${tableId}`, body); // Rename goes through the /name sub-resource (a bare PUT .../table/:id 404s).
const base = baseId ?? this.cfg.baseId;
return this.call<unknown>('PUT', `/api/base/${base}/table/${tableId}/name`, body);
} }
deleteTable(tableId: string, baseId?: string) { deleteTable(tableId: string, baseId?: string) {
const base = baseId ?? this.cfg.baseId; const base = baseId ?? this.cfg.baseId;
@@ -131,20 +148,29 @@ export class Client {
} }
return out; return out;
} }
getRecord(recordId: string, query?: Record<string, unknown>) { getRecord(tableId: string, recordId: string, query?: Record<string, unknown>) {
// Route is table-scoped: GET /api/table/:tableId/record/:recordId
const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : ''; const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : '';
return this.call<unknown>('GET', `/api/record/${recordId}${qs}`); return this.call<unknown>('GET', `/api/table/${tableId}/record/${recordId}${qs}`);
} }
createRecord(tableId: string, body: unknown) { createRecord(tableId: string, body: unknown, query?: Record<string, unknown>) {
return this.call<unknown>('POST', `/api/table/${tableId}/record`, body); const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : '';
return this.call<unknown>('POST', `/api/table/${tableId}/record${qs}`, body);
} }
updateRecord(tableId: string, body: unknown) { updateRecord(tableId: string, body: unknown, query?: Record<string, unknown>) {
return this.call<unknown>('PATCH', `/api/table/${tableId}/record`, body); const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : '';
return this.call<unknown>('PATCH', `/api/table/${tableId}/record${qs}`, body);
} }
updateRecordOne(recordId: string, body: unknown) { updateRecordOne(tableId: string, recordId: string, body: unknown, query?: Record<string, unknown>) {
return this.call<unknown>('PATCH', `/api/record/${recordId}`, body); const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : '';
return this.call<unknown>('PATCH', `/api/table/${tableId}/record/${recordId}${qs}`, body);
} }
deleteRecords(tableId: string, recordIds: string[]) { deleteRecords(tableId: string, recordIds: string[]) {
if (recordIds.length === 1) {
// Bulk query ?recordIds=x parses as a string and 400s — single deletes
// use the per-record route.
return this.call<unknown>('DELETE', `/api/table/${tableId}/record/${recordIds[0]}`);
}
const qs = recordIds.map((id) => `recordIds=${encodeURIComponent(id)}`).join('&'); const qs = recordIds.map((id) => `recordIds=${encodeURIComponent(id)}`).join('&');
return this.call<unknown>('DELETE', `/api/table/${tableId}/record?${qs}`); return this.call<unknown>('DELETE', `/api/table/${tableId}/record?${qs}`);
} }
@@ -153,17 +179,47 @@ export class Client {
listViews(tableId: string) { listViews(tableId: string) {
return this.call<unknown[]>('GET', `/api/table/${tableId}/view`); return this.call<unknown[]>('GET', `/api/table/${tableId}/view`);
} }
getView(viewId: string) { getView(tableId: string, viewId: string) {
return this.call<unknown>('GET', `/api/view/${viewId}`); // View routes are table-scoped: /api/view/:viewId alone does not exist.
return this.call<unknown>('GET', `/api/table/${tableId}/view/${viewId}`);
} }
createView(tableId: string, body: unknown) { createView(tableId: string, body: unknown) {
return this.call<unknown>('POST', `/api/table/${tableId}/view`, body); return this.call<unknown>('POST', `/api/table/${tableId}/view`, body);
} }
updateView(viewId: string, body: unknown) { /** Update view parts — each key goes to its own sub-resource route. */
return this.call<unknown>('PATCH', `/api/view/${viewId}`, body); async updateView(
tableId: string,
viewId: string,
parts: {
name?: string;
description?: string;
filter?: unknown;
sort?: unknown;
group?: unknown;
columnMeta?: unknown;
options?: unknown;
}
): Promise<unknown> {
const base = `/api/table/${tableId}/view/${viewId}`;
let last: unknown;
if (parts.name !== undefined) last = await this.call('PUT', `${base}/name`, { name: parts.name });
if (parts.description !== undefined)
last = await this.call('PUT', `${base}/description`, { description: parts.description });
if (parts.filter !== undefined) last = await this.call('PUT', `${base}/filter`, { filter: parts.filter });
if (parts.sort !== undefined) {
// The API wants {sortObjs:[...]}; accept the natural bare-array form too.
const sort = Array.isArray(parts.sort) ? { sortObjs: parts.sort } : parts.sort;
last = await this.call('PUT', `${base}/sort`, { sort });
}
if (parts.group !== undefined) last = await this.call('PUT', `${base}/group`, { group: parts.group });
if (parts.columnMeta !== undefined)
last = await this.call('PUT', `${base}/column-meta`, parts.columnMeta);
if (parts.options !== undefined)
last = await this.call('PATCH', `${base}/options`, { options: parts.options });
return last;
} }
deleteView(viewId: string) { deleteView(tableId: string, viewId: string) {
return this.call<{ id: string }>('DELETE', `/api/view/${viewId}`); return this.call<{ id: string }>('DELETE', `/api/table/${tableId}/view/${viewId}`);
} }
// ---- view preset (self-built contract) ---- // ---- view preset (self-built contract) ----
@@ -297,10 +353,6 @@ export class Client {
} }
// ---- SQL (read-only) ---- // ---- SQL (read-only) ----
sql(baseId: string, sql: string) {
return this.call<unknown[]>('POST', `/api/base/${baseId}/sql`, { sql });
}
// ---- plugin ---- // ---- plugin ----
getPlugins() { getPlugins() {
return this.call<unknown[]>('GET', '/api/plugin'); return this.call<unknown[]>('GET', '/api/plugin');
+33 -1
View File
@@ -7,6 +7,36 @@ import { resolveConfig } from './config';
import { Client, ApiError } from './client'; import { Client, ApiError } from './client';
import { describeError } from './request'; import { describeError } from './request';
/** Actionable hints for recurring backend error shapes — the goal is that an
* agent (or human) reading the error knows the next command to try, instead
* of spending a round-trip re-deriving the cause. */
export function buildHint(status: number, message: string): string | undefined {
const m = message || '';
if (/Invalid tableId/i.test(m))
return '表不存在或不属于当前 base —— 用 `inkreach-teable table list` 核对 ID;确认 --base-id / INKREACH_TEABLE_BASE_ID 指向正确的基地。';
if (/Workflow not found/i.test(m))
return '工作流不存在(或已被软删除)—— 用 `inkreach-teable workflow list` 核对 ID;workflow delete 是软删除,列表里可能已不可见。';
if (/Cannot (GET|POST|PUT|PATCH|DELETE)/i.test(m))
return '路由不存在 —— CLI 与后端版本可能不匹配,升级 CLI:`npm i -g @inkreach/inkreach-teable`;仍失败则用 --debug 反馈。';
if (status === 401)
return '鉴权失败 —— `inkreach-teable auth status` 检查 token;必要时到 Web UI Settings → Personal Access Tokens 重新签发并 `auth login`。';
if (status === 403 || /restricted_resource|Forbidden/i.test(m))
return '权限不足 —— 该操作需要更高角色(base owner/creator)或 PAT 授权范围不含此 base;插件上传还需 plugin_developer 白名单。';
if (/Invalid value for field|Invalid option/i.test(m))
return '值不在字段合法选项内 —— `inkreach-teable field list <tableId> --with-options` 查看可选值;新增选项用 `field update <tableId> <field> --options`。';
if (/does not meet the current row conditions/i.test(m))
return '违反了字段的 visibilityFilter(行级条件)—— `inkreach-teable field visible-choices <tableId> <recordId> <field>` 查看该行可见选项。';
if (/expected array, received/i.test(m))
return '参数形状不符 —— 批量接口要数组;单条可改用 --fields,批量用 --records。';
if (status === 429)
return '触发限流 —— 稍等后重试;批量操作请降低并发或分批执行。';
if (/timeout|ETIMEDOUT|ECONNRESET/i.test(m))
return '请求超时/连接中断 —— 先用只读命令(如 table list)确认服务可达,再重试原操作。';
if (/VALIDATION|Validation error/i.test(m) && status === 400)
return '请求校验失败 —— 用 --json 查看完整错误;对照 `<cmd> --help` 的参数示例。';
return undefined;
}
export interface Ctx { export interface Ctx {
client: Client; client: Client;
json: boolean; json: boolean;
@@ -62,10 +92,12 @@ export function run<TArgs extends unknown[] = unknown[]>(
await fn(ctx, positional, options); await fn(ctx, positional, options);
} catch (err) { } catch (err) {
const { status, code, message } = describeError(err); const { status, code, message } = describeError(err);
const hint = status ? buildHint(status, message) : undefined;
if (ctx.json) { if (ctx.json) {
process.stdout.write(JSON.stringify({ error: { status, code, message } }) + '\n'); process.stdout.write(JSON.stringify({ error: { status, code, message, ...(hint ? { hint } : {}) } }) + '\n');
} else { } else {
process.stderr.write(`✗ ${code}${status ? ` (${status})` : ''}: ${message}\n`); process.stderr.write(`✗ ${code}${status ? ` (${status})` : ''}: ${message}\n`);
if (hint) process.stderr.write(` hint: ${hint}\n`);
} }
process.exitCode = 1; process.exitCode = 1;
} }