Compare commits

..
14 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
36 changed files with 2714 additions and 336 deletions
+29 -3
View File
@@ -1,14 +1,40 @@
# inkreach-teable-cli # inkreach-teable-cli
把下面这段引导词发给任意机器 / 任意会话中的 Agent,即可完成安装并开始使用: inkreach-teable 实例的运维 CLI(npm 包 `@inkreach/inkreach-teable`)与
**全部 Agent Skills 的唯一维护仓库**。
## Skills(本仓库 `skills/` 目录 = single source of truth)
| Skill | 用途 |
|---|---|
| `inkreach-teable` | 操作已部署实例:表格/字段/记录/视图/自动化日常运维、REST API 参考 |
| `inkreach-teable-plugin` | 开发动态 React 组件插件:IIFE bundle(webpack/vite 双模板)、多资产上传(chunk + sourcemap)、`teable-plugin dev` 热更新闭环、5 个挂载位(含整页 route) |
**维护规则**:所有 skill 只在本仓库修改;主仓库(inkreach-teable)不含任何
skill 源文件。改完后同步到安装位置(`~/.zcode/skills/` 为主,
`~/.agents/skills/` 为镜像):
```bash
cp -r skills/<name>/SKILL.md skills/<name>/metadata.json skills/<name>/dynamic-plugins ~/.zcode/skills/<name>/
cp -r skills/<name>/ ~/.agents/skills/<name>/ # 按需镜像
```
## 安装(任意机器 / 任意会话的 Agent)
把下面这段引导词发给 Agent 即可完成安装并开始使用:
```text ```text
请安装并用 inkreach-teable CLI 操作 inkreach-teable: 请安装并用 inkreach-teable CLI 操作 inkreach-teable:
1. 安装 cli: 1. 安装 cli:
npm i -g git+https://gitea.inkreach.cc/inkreachhub/inkreach-teable-cli.git npm i -g @inkreach/inkreach-teable
2. 安装技能: 2. 安装技能:
npx -y skills add https://gitea.inkreach.cc/inkreachhub/inkreach-teable-cli.git -g -y --copy npx -y skills add https://gitea.inkreach.cc/inkreachhub/inkreach-teable-cli.git -g -y --copy
3. 鉴权(PAT 从 Web UI Settings → Personal Access Tokens 获取): 3. 鉴权(PAT 从 Web UI Settings → Personal Access Tokens 获取):
inkreach-teable auth login --endpoint https://table.inkreach.cc --token <PAT> --base-id bseXXX inkreach-teable auth login --endpoint https://table.inkreach.cc --token <PAT> --base-id bseXXX
inkreach-teable auth status
``` ```
## 目录结构
- `skills/` — Agent Skills(本仓库的核心资产之一)
- `src/` — CLI 源码(commander 实现)
- `docs/cli-skill-design.md` — CLI + Skill 的设计文档
+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": "我想大概每天知道一下订单进展。邮件先不用,站内通知就行。你看着帮我弄一个。"
}
]
}
+5 -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,7 +12,7 @@
"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": [ "files": [
@@ -26,7 +26,8 @@
"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" "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
+51 -17
View File
@@ -13,7 +13,7 @@ description: >-
"I need a dashboard widget" — or wants the native form embedded or "I need a dashboard widget" — or wants the native form embedded or
extended inside a plugin (原生表单, 表单注入, 表单虚拟字段, custom form extended inside a plugin (原生表单, 表单注入, 表单虚拟字段, custom form
layout, NativeForm, NativeFormScope, form panel) — load this skill. layout, NativeForm, NativeFormScope, form panel) — load this skill.
Reference example at plugins/batch-edit-pro/. Reference example at `plugins/batch-edit-pro/` (on the deployment host: `/root/deployments/inkreach-teable/plugins/batch-edit-pro/`).
--- ---
# inkreach-teable dynamic plugin development # inkreach-teable dynamic plugin development
@@ -23,18 +23,45 @@ 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 JS that executes in the host page and accesses host APIs through
`window.__TEABLE_HOST__`. `window.__TEABLE_HOST__`.
> **Contract**: A plugin = a webpack IIFE bundle (`dist/main.js` + optional > **Contract**: A plugin = a webpack (or vite) IIFE bundle (`dist/main.js` +
> `dist/styles.css`) + `plugin.json` manifest, uploaded to the host via > optional lazy chunks `*.chunk.js`, source maps `*.js.map`, `styles.css`) +
> `POST /api/plugin/:id/upload-bundle`. It runs in the same React tree as the host > a `plugin.json` manifest, uploaded to the host via
> and has full access to the host's SDK, UI library, and OpenAPI helpers. > `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.
> >
> **Critical constraint**: NEVER `import` from `@teable/sdk`, `@teable/ui-lib`, > **Dev loop**: `teable-plugin dev` (or `pnpm dev` in scaffolded projects)
> `@teable/openapi` or `react` in plugin code. These are mapped to > watch-builds and auto-uploads with a `devMode` manifest; the open host page
> `window.__TEABLE_HOST__.*` by webpack externals at build time — importing > remounts the plugin within ~4s without a page refresh. See
> them produces `undefined` at runtime. Always read from the host global: > `dynamic-plugins/dev-workflow.md`.
> `const { useFields } = window.__TEABLE_HOST__.SDK`. See >
> **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. > `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 ## 1. When to use this skill
- User says "写个插件 / 做个插件 / make a plugin" — yes. - User says "写个插件 / 做个插件 / make a plugin" — yes.
@@ -55,9 +82,11 @@ JS that executes in the host page and accesses host APIs through
| 1. Understand architecture | `dynamic-plugins/overview.md` | always first — how `window.__TEABLE_HOST__`, PluginLoader, PluginSlot work | | 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 | | 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 | | 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 | | 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) | | 5. Troubleshooting | `dynamic-plugins/troubleshooting.md` | before writing logic — common pitfalls (host globals, developer whitelist, AM, empty states, chunk-name flattening, terser banners) |
| Ref: batch-edit-pro | `plugins/batch-edit-pro/` | the canonical example — contextMenu plugin, full React + SDK | | 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? ## 3. Quick decision: which position?
@@ -73,13 +102,18 @@ JS that executes in the host page and accesses host APIs through
- Endpoint: `http://localhost:3003` (backend; `:3000` is taken by an unrelated - Endpoint: `http://localhost:3003` (backend; `:3000` is taken by an unrelated
docker container on this host — see AGENTS.md), frontend `http://localhost:3002`. docker container on this host — see AGENTS.md), frontend `http://localhost:3002`.
- Test account: create a throwaway via `POST /api/auth/signup` — the old - Plugin upload requires a `plugin_developer` row for the uploading user
`hr_admin@xingchen.tech` account no longer exists on this instance. Plugin
upload additionally requires a `plugin_developer` row for the uploading user
(`INSERT INTO plugin_developer (id, user_id, email, created_time, created_by) (`INSERT INTO plugin_developer (id, user_id, email, created_time, created_by)
VALUES ('plgd<name>', '<userId>', '<email>', now(), '<userId>')`). VALUES ('plgd<name>', '<userId>', '<email>', now(), '<userId>')`).
- Test base: `bseAi6Hazmw7jPWZbfO`, test table: `tbldtYd4ZhvkcyAaada`. - **Sandbox base for any hands-on test**: create a throwaway base
- Auth: session cookie `auth_session`, or `inkreach-teable auth login`. (`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 ## 5. Start here
@@ -7,9 +7,18 @@ Uploading a bundle plugin, enabling it, and managing permissions.
Upload the built bundle to the host. Uses `multipart/form-data`. Upload the built bundle to the host. Uses `multipart/form-data`.
The `pluginId` in the URL must match the `id` in `plugin.json`. 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 ```bash
PLUGIN_ID=$(python3 -c "import json; print(json.load(open('plugin.json'))['id'])") PLUGIN_ID=$(python3 -c "import json; print(json.load(open('plugin.json'))['id'])")
curl -s -b "auth_session=$SESSION" \ curl -s -H "Authorization: Bearer $TEABLE_PAT" \
-F "js=@dist/main.js" \ -F "js=@dist/main.js" \
-F "css=@dist/styles.css" \ -F "css=@dist/styles.css" \
-F "manifest=@plugin.json" \ -F "manifest=@plugin.json" \
@@ -17,28 +26,51 @@ curl -s -b "auth_session=$SESSION" \
``` ```
**Auth note**: Supports both session cookies (`auth_session`) and Personal Access **Auth note**: Supports both session cookies (`auth_session`) and Personal Access
Tokens (`Authorization: Bearer <PAT>`). 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:** **Fields** (v2 multi-asset, since 2026-10):
| Field | Required | Max size | Description |
|---|---|---|---|
| `js` | Yes | 5 MB | The IIFE bundle (`dist/main.js`) |
| `css` | No | 1 MB | Optional stylesheet (`dist/styles.css`) |
| `manifest` | Yes | 10 KB | `plugin.json` manifest |
**Returns** `201` with `{ id, name, version, ... }` — the plugin ID to use for | Field | Required | Max count | Max size | Description |
enable and install. |---|---|---|---|---|
| `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): **Security** (7-layer validation):
1. Authentication — valid session cookie required (401 if missing) 1. Authentication — valid session cookie or PAT required (401 if missing)
2. Developer whitelist — `plugin_developer` table lookup (403 if not whitelisted) 2. Developer whitelist — `plugin_developer` table lookup (403 if not whitelisted)
3. Rate limit — 10 uploads/min per user (429 if exceeded) 3. Rate limit — 10 uploads/min per user (429 if exceeded)
4. Size limits — JS ≤ 5 MB, CSS ≤ 1 MB, manifest ≤ 10 KB 4. Size limits — JS/map ≤ 5 MB each, CSS ≤ 1 MB each, manifest ≤ 10 KB,
5. Manifest validation — required fields, `id` regex `[a-z0-9-]+` 15 MB total
6. Content validation — JS must be IIFE format (prefix `!function`/`(()=>`), 5. Manifest validation — required fields, `id` regex `[a-z0-9-]+`,
rejects `<script>`/`eval`/`document.write` injection; CSS strips `expression()`/ `minAppVersion` ≤ host version
`@import`/`javascript:` URIs 6. Content validation — the ENTRY must be IIFE format (prefix `!function` /
7. Integrity — SHA-256 hash stored in DB, verified at load time `(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` ## Enable — `PUT /api/base/:baseId/plugin-config/:pluginId`
@@ -112,11 +144,28 @@ must call `ctx.addView(id, component)` to render the component as a view.
Authority Matrix entries are auto-created for the new install (default Authority Matrix entries are auto-created for the new install (default
denied for non-owner roles). denied for non-owner roles).
### Route ### Route (full-page app, since 2026-10)
Route-type plugins registered via `ctx.addRoute(path, component)` are The host renders a dedicated page for the plugin at
automatically available at the registered path — no install API needed. `/base/<baseId>/p/<pluginId>` — the whole main-area viewport inside the base
Upload + enable is sufficient. 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) ## Authority Matrix (plugin permissions)
@@ -144,16 +193,15 @@ VALUES (gen_random_uuid(), '<userId>', '<email>', '<adminUserId>');
## Getting a session cookie ## 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 ```bash
curl -s -c /tmp/cookie_hr.txt http://localhost:3003/api/auth/signin \ curl -s -c /tmp/cookie.txt http://localhost:3003/api/auth/signin \
-X POST -H 'Content-Type: application/json' \ -X POST -H 'Content-Type: application/json' \
-d '{"email":"hr_admin@xingchen.tech","password":"Xingchen@2026"}' -d '{"email":"<real-account>","password":"<password>"}'
SESSION=$(grep auth_session /tmp/cookie.txt | awk '{print $NF}')
> Account note (2026-08): `hr_admin@xingchen.tech` no longer exists on the
> deployed instance. Create a throwaway user via `POST /api/auth/signup`
> and add a `plugin_developer` row for it before uploading (see SKILL.md).
SESSION=$(grep auth_session /tmp/cookie_hr.txt | awk '{print $NF}')
``` ```
## Full deploy script (copy-paste) ## Full deploy script (copy-paste)
@@ -163,30 +211,23 @@ SESSION=$(grep auth_session /tmp/cookie_hr.txt | awk '{print $NF}')
set -e set -e
DIR="${1:-.}" DIR="${1:-.}"
UPLOAD_URL="${UPLOAD_URL:-http://localhost:3003}" UPLOAD_URL="${UPLOAD_URL:-http://localhost:3003}"
BASE_ID="${BASE_ID:-bseAi6Hazmw7jPWZbfO}" BASE_ID="${BASE_ID:?set to a throwaway sandbox base, never a production base}"
# Read plugin ID from manifest # Read plugin ID from manifest
PLUGIN_ID=$(python3 -c "import json; print(json.load(open('$DIR/plugin.json'))['id'])") PLUGIN_ID=$(python3 -c "import json; print(json.load(open('$DIR/plugin.json'))['id'])")
# Get session # Upload: teable-plugin deploy handles entry + chunks + maps + css.
SESSION=$(grep auth_session /tmp/cookie_hr.txt 2>/dev/null | awk '{print $NF}') # Raw-curl equivalent for a single-file bundle:
if [ -z "$SESSION" ]; then # curl -s -H "Authorization: Bearer $TEABLE_PAT" \
curl -s -c /tmp/cookie_hr.txt "$UPLOAD_URL/api/auth/signin" \ # -F "js=@$DIR/dist/main.js" -F "css=@$DIR/dist/styles.css" \
-X POST -H 'Content-Type: application/json' \ # -F "manifest=@$DIR/plugin.json" \
-d '{"email":"hr_admin@xingchen.tech","password":"Xingchen@2026"}' > /dev/null # "$UPLOAD_URL/api/plugin/$PLUGIN_ID/upload-bundle"
SESSION=$(grep auth_session /tmp/cookie_hr.txt | awk '{print $NF}') TEABLE_ENDPOINT="$UPLOAD_URL" TEABLE_PAT="${TEABLE_PAT:?set PAT env}" \
fi node /root/deployments/inkreach-teable/packages/plugin-cli/dist/index.js deploy -d "$DIR"
# Upload (URL includes pluginId from manifest)
curl -s -b "auth_session=$SESSION" \
-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"
echo "Uploaded: $PLUGIN_ID" echo "Uploaded: $PLUGIN_ID"
# Enable # Enable
curl -s -b "auth_session=$SESSION" -X PUT \ curl -s -H "Authorization: Bearer $TEABLE_PAT" -X PUT \
"$UPLOAD_URL/api/base/$BASE_ID/plugin-config/$PLUGIN_ID" \ "$UPLOAD_URL/api/base/$BASE_ID/plugin-config/$PLUGIN_ID" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"enabled":true,"tableIds":[]}' > /dev/null -d '{"enabled":true,"tableIds":[]}' > /dev/null
@@ -1,18 +1,37 @@
# Plugin development workflow # Plugin development workflow
The standard recipe for creating, building, and deploying a dynamic React The standard recipe for creating, building, and deploying a dynamic React
component plugin. Each plugin is a standalone webpack project that outputs component plugin. Each plugin is a standalone webpack (or vite) project that
an IIFE bundle. 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 ## Output contract
Every plugin task ends with: Every plugin task ends with:
1. A built IIFE bundle (`dist/main.js` + optional `dist/styles.css`) 1. A built IIFE bundle (`dist/main.js` + optional chunks/`*.chunk.js` +
`*.js.map` source maps + optional `styles.css`)
2. A `plugin.json` manifest 2. A `plugin.json` manifest
3. Uploaded to the host via `POST /api/plugin/:id/upload-bundle` 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` 4. Enabled via `PUT /api/base/:baseId/plugin-config/:pluginId`
5. Installed to the target position (the install API varies by position) 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 ## Step 1 — Understand the request
Clarify what the user wants, then pick the position using `overview.md`. Clarify what the user wants, then pick the position using `overview.md`.
@@ -26,23 +45,33 @@ to npm). Run it directly:
```bash ```bash
cd /tmp cd /tmp
node /root/deployments/inkreach-teable/packages/plugin-cli/dist/index.js create my-plugin 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 cd my-plugin
pnpm install 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: This generates:
``` ```
my-plugin/ my-plugin/
├── src/ ├── src/
│ └── index.tsx # entry: calls definePlugin() │ ├── host.ts # typed host accessor
│ └── index.tsx # entry: definePlugin via host.PluginSDK
├── plugin.json # manifest ├── plugin.json # manifest
├── webpack.config.js # pre-configured externals ├── webpack.config.js # or vite.config.ts
├── tsconfig.json ├── tsconfig.json
└── package.json └── package.json # dev/build/deploy scripts
``` ```
If the CLI doesn't work for some reason, manually create the structure by Reference implementations in the repo: `plugins/plugin-showcase/` (chunks +
copying `plugins/batch-edit-pro/` as a template. sourcemap + Tailwind + HashRouter route app — the v2 showcase) and
`plugins/batch-edit-pro/` (classic single-file contextMenu plugin).
## Step 3 — Write the plugin code ## Step 3 — Write the plugin code
@@ -117,59 +146,114 @@ definePlugin({
## Step 4 — Build ## Step 4 — Build
```bash
NODE_OPTIONS='--max-old-space-size=2048' npx webpack --config webpack.config.js
```
Or if the plugin has a build script:
```bash ```bash
pnpm build pnpm build
# or: npm run build # = tsc --noEmit && webpack --mode production (scaffold script)
``` ```
Outputs: `dist/main.js` (IIFE) + `dist/styles.css` (if styles exist). **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):
Verify the output: ```js
```bash // webpack.config.js — known-good v2 (from plugins/plugin-showcase/)
ls -la dist/main.js dist/styles.css plugin.json 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 ## Step 5 — Upload the bundle
The upload URL includes the plugin's `id` from `plugin.json`. Extract it first, Preferred (handles entry + chunks + maps + css automatically):
then upload.
```bash ```bash
SESSION=$(grep auth_session /tmp/cookie_hr.txt 2>/dev/null | awk '{print $NF}') TEABLE_ENDPOINT=http://localhost:3003 TEABLE_PAT=<pat> \
if [ -z "$SESSION" ]; then node ../../packages/plugin-cli/dist/index.js deploy -d .
SESSION=$(curl -s -c - http://localhost:3003/api/auth/signin \ # or from the plugin dir: pnpm deploy
-X POST -H 'Content-Type: application/json' \ ```
-d '{"email":"hr_admin@xingchen.tech","password":"Xingchen@2026"}' \
| grep auth_session | awk '{print $NF}')
> Account note (2026-08): `hr_admin@xingchen.tech` no longer exists on the Raw curl equivalent (single-file legacy form still works):
> deployed instance. Create a throwaway user via `POST /api/auth/signup`
> and add a `plugin_developer` row for it before uploading (see SKILL.md).
fi
```bash
PLUGIN_ID=$(python3 -c "import json; print(json.load(open('plugin.json'))['id'])") PLUGIN_ID=$(python3 -c "import json; print(json.load(open('plugin.json'))['id'])")
curl -s -H "Authorization: Bearer $TEABLE_PAT" \
curl -s -b "auth_session=$SESSION" \
-F "js=@dist/main.js" \ -F "js=@dist/main.js" \
-F "css=@dist/styles.css" \ -F "css=@dist/styles.css" \
-F "manifest=@plugin.json" \ -F "manifest=@plugin.json" \
"http://localhost:3003/api/plugin/$PLUGIN_ID/upload-bundle" "http://localhost:3003/api/plugin/$PLUGIN_ID/upload-bundle"
echo "PLUGIN_ID=$PLUGIN_ID"
``` ```
- The plugin `id` in the URL must match the `id` in `plugin.json`. On first Multipart notes (v2, 2026-10):
upload, the plugin is created; on subsequent uploads, the existing plugin is - `js` field accepts up to 20 files (entry first or matched by manifest
updated. entry filename), `css` up to 5, `map` up to 20.
- Auth supports both session cookies (`auth_session`) and Personal Access Tokens - The plugin row must already exist (`POST /api/plugin {"id": "<kebab-id>"}`
(`Authorization: Bearer <PAT>`). — custom ids require the plugin_developer whitelist; the endpoint is
- Returns 201 on success. `@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) ## Step 6 — Enable (REQUIRED before install)
@@ -203,6 +287,11 @@ Open the relevant page and confirm the plugin renders and works:
- Panel: bottom-right Plugins button in a table view - Panel: bottom-right Plugins button in a table view
- View: view-type dropdown - View: view-type dropdown
- Dashboard: space dashboard page - 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. Use `browser-act` to verify or ask the user to check.
@@ -210,14 +299,18 @@ Use `browser-act` to verify or ask the user to check.
Before claiming done: Before claiming done:
- [ ] `dist/main.js` built and is a valid IIFE bundle - [ ] `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` - [ ] `plugin.json` at project root with correct `id`, `entry`, `positions`
- [ ] No hardcoded URLs/table IDs/tokens — all via host SDK - [ ] No hardcoded URLs/table IDs/tokens — all via host SDK
- [ ] Loading, error, and empty states handled in the UI - [ ] Loading, error, and empty states handled in the UI
- [ ] `definePlugin()` called with `onLoad(ctx)` that registers at least one component - [ ] `definePlugin()` called with `onLoad(ctx)` that registers at least one component
- [ ] Upload returns 201, pluginId captured - [ ] Upload returns 2xx (assets count > 1 when chunks/maps exist)
- [ ] Enable (plugin-config) called before install - [ ] Enable (plugin-config) called before install
- [ ] Install returns 2xx - [ ] Install returns 2xx
- [ ] Plugin renders and works - [ ] 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. 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` 章节。
@@ -49,11 +49,17 @@ gated by a developer whitelist and bundles are validated for security.
→ check minAppVersion compatibility → check minAppVersion compatibility
→ <link> inject CSS (if manifest.style exists) → <link> inject CSS (if manifest.style exists)
→ <script src="/api/plugin/:id/runtime/main.js"> (IIFE executes) → <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, ... }) → script calls definePlugin({ id, name, onLoad, ... })
→ window.__TEABLE_PLUGINS__[pluginId] = config → window.__TEABLE_PLUGINS__[pluginId] = config
→ PluginLoader reads registration, creates PluginContext, calls onLoad(ctx) → PluginLoader reads registration, creates PluginContext, calls onLoad(ctx)
→ ctx.addContextMenuAction(...) etc. register React components to componentRegistry → ctx.addContextMenuAction(...) etc. register React components to componentRegistry
→ PluginSlot renders registered components at the right mount point → 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 3. Plugin unloads
→ onUnload(ctx) called → onUnload(ctx) called
@@ -117,32 +123,41 @@ Location: `apps/nextjs-app/src/features/app/components/plugin/host/event-bus.ts`
``` ```
dist/ dist/
├── main.js # IIFE bundle (webpack output) ├── main.js # IIFE entry bundle (webpack/vite output)
└── styles.css # optional stylesheet ├── <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 plugin.json # manifest
``` ```
## Shared dependency bridging ## Shared dependency bridging
Plugin webpack config marks these as externals, mapping to host globals: Plugin webpack config marks ONLY the React singleton as external, mapping to
a host global:
```javascript ```javascript
externals: { externals: {
'react': 'window.__TEABLE_HOST__.React', 'react': 'window.__TEABLE_HOST__.React',
'react-dom': 'window.__TEABLE_HOST__.ReactDOM', 'react-dom': 'window.__TEABLE_HOST__.ReactDOM',
'@teable/sdk': 'window.__TEABLE_HOST__.SDK',
'@teable/ui-lib': 'window.__TEABLE_HOST__.UILib',
'@teable/openapi': 'window.__TEABLE_HOST__.OpenAPI',
'@teable/plugin-sdk': 'window.__TEABLE_HOST__.PluginSDK',
} }
``` ```
This means the plugin does NOT bundle React or host packages — it uses the Everything else on the host (`SDK`, `UILib`, `OpenAPI`, `PluginSDK`) is read
host's singleton instances. The bundle only contains the plugin's own code. 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 ## Two ways to access host APIs
### 1. Through `window.__TEABLE_HOST__` (recommended for IIFE) ### 1. Through `window.__TEABLE_HOST__` (the only runtime path)
```javascript ```javascript
const host = window.__TEABLE_HOST__; const host = window.__TEABLE_HOST__;
@@ -153,17 +168,21 @@ const { getRecords } = host.OpenAPI;
const { definePlugin, useSelection } = host.PluginSDK; const { definePlugin, useSelection } = host.PluginSDK;
``` ```
This is what `plugins/batch-edit-pro/` uses — the plugin bundle has no This is what `plugins/plugin-showcase/` and `plugins/batch-edit-pro/` use —
`@teable/*` imports, everything resolves through the host global. 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. Through `@teable/plugin-sdk` (also works because webpack externals) ### 2. `import type` from `@teable/*` (type-checking only)
```typescript ```typescript
import { definePlugin } from '@teable/plugin-sdk'; import type { ITeableHost } from '@teable/plugin-sdk';
export const host = window.__TEABLE_HOST__ as unknown as ITeableHost;
``` ```
At build time, webpack resolves this to `window.__TEABLE_HOST__.PluginSDK.definePlugin`. `import type` is erased at compile time — it never reaches the bundle. This
Both styles work — the externals config handles the mapping. is how scaffolded projects get full type safety inside the monorepo
(standalone scaffolds ship a loose-typed local host module instead).
## Positions and registration ## Positions and registration
@@ -173,4 +192,4 @@ Both styles work — the externals config handles the mapping.
| Panel | Right-side drawer of a table view | `ctx.addPanel(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)` | | View | View-type dropdown (alongside grid/form/gallery) | `ctx.addView(id, component)` |
| Dashboard | Grid card on space dashboard | `ctx.addDashboardWidget(id, component)` | | Dashboard | Grid card on space dashboard | `ctx.addDashboardWidget(id, component)` |
| Route | Full-page route (e.g., `/plugin/:pluginId/my-page`) | `ctx.addRoute(path, component)` | | Route | Full-page app at `/base/:baseId/p/:pluginId` (whole viewport inside BaseLayout; own router OK, HashRouter recommended) | `ctx.addRoute(id, component)` |
@@ -59,9 +59,17 @@ ctx.addContextMenuAction(id: string, component: React.FC): Disposable;
ctx.addPanel(id: string, component: React.FC): Disposable; ctx.addPanel(id: string, component: React.FC): Disposable;
ctx.addDashboardWidget(id: string, component: React.FC): Disposable; ctx.addDashboardWidget(id: string, component: React.FC): Disposable;
ctx.addView(id: string, component: React.FC): Disposable; ctx.addView(id: string, component: React.FC): Disposable;
ctx.addRoute(path: 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 ### Events
#### `ctx.on(event, handler): Disposable` #### `ctx.on(event, handler): Disposable`
@@ -104,3 +104,46 @@ If you're setting up the webpack config manually, make sure:
- The plugin does NOT need `import React from 'react'` at the top when using - 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 `react-jsx` transform — JSX is auto-transpiled. But you still need
`const React = host.React` for any explicit `React.createElement` calls. `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.
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"version": "1.0.0", "version": "1.1.0",
"organization": "inkreach", "organization": "inkreach",
"authors": ["inkreach"], "authors": ["inkreach"],
"tags": ["plugin", "teable", "zip", "bridge", "context-menu", "dashboard", "panel", "view", "iframe"], "tags": ["plugin", "teable", "bundle", "iife", "webpack", "vite", "context-menu", "dashboard", "panel", "view", "route", "hot-reload", "source-map"],
"abstract": "Develop ZIP plugins for inkreach-teable: 4 mount positions, penpal bridge API, AI-friendly templates and examples." "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."
} }
+24 -16
View File
@@ -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,13 +9,20 @@ 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: `npm i -g git+https://gitea.inkreach.cc/inkreachhub/inkreach-teable-cli.git` (the `git+` prefix is REQUIRED for npm/npx; ad-hoc without installing: `npx git+https://gitea.inkreach.cc/inkreachhub/inkreach-teable-cli.git <cmd>`). Offline fallback: `bash scripts/install.sh` relative to this skill dir in a repo clone. - 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 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). - **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,13 @@ 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) | | 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) |
@@ -58,12 +55,14 @@ 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) | | 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) |
@@ -84,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 https://table.inkreach.cc --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
@@ -110,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.
@@ -43,7 +43,7 @@ Verified route-for-route against `src/lib/client.ts` (the CLI's HTTP layer).
List-query params (objects are passed **JSON-stringified**; repeated keys use List-query params (objects are passed **JSON-stringified**; repeated keys use
`key[]`): `filter` (JSON), `projection` (JSON array), `search[]` (keyword — `key[]`): `filter` (JSON), `projection` (JSON array), `search[]` (keyword —
MUST be the `search[]` tuple form, plain `search=` is ignored), `skip` / `take` MUST be the `search[]` tuple form, plain `search=` is ignored), `skip` / `take`
(pagination; no `orderBy` on this fork — sort via view or `sql`). Response: (pagination; no `orderBy` on this fork — sort via a view). Response:
`{ records: [{id, fields}], total? }`. `{ records: [{id, fields}], total? }`.
### Fields ### Fields
@@ -83,7 +83,6 @@ MUST be the `search[]` tuple form, plain `search=` is ignored), `skip` / `take`
| Method & route | Purpose | | Method & route | Purpose |
|---|---| |---|---|
| `POST /api/base/{bid}/sql` | Read-only SQL (`{sql}`; JOIN/GROUP BY; no record ids) |
| `GET /api/table/{tid}/aggregation/row-count` | Total row count (`{"rowCount":N}` — verified) | | `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 /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 | | `GET`/`POST` `/api/access-token`, `DELETE /api/access-token/{id}` | PAT management |
@@ -153,6 +152,16 @@ const res = await fetch(triggerInput._apiBaseUrl + "/api/table/tblB/record", {
`401` = bad/expired token, `403` = scope or base-role too low. `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 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. `--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) ## 5. Full Route Inventory (this guide ≠ the whole API)
-33
View File
@@ -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": []
}
]
}
+25 -19
View File
@@ -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>
+41 -13
View File
@@ -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 |
+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;
}
+300 -38
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 —
// flatten and keep only actual id strings.
const ids = [
...new Set(
[
...(opts.recordIds
? opts.recordIds
.split(',') .split(',')
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean); .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;
} }
deleteView(viewId: string) { ): Promise<unknown> {
return this.call<{ id: string }>('DELETE', `/api/view/${viewId}`); 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(tableId: string, viewId: string) {
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;
} }