Compare commits

..
10 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
35 changed files with 2622 additions and 275 deletions
+29 -4
View File
@@ -1,15 +1,40 @@
# 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
请安装并用 inkreach-teable CLI 操作 inkreach-teable:
1. 安装 cli(npm 公共包):
1. 安装 cli:
npm i -g @inkreach/inkreach-teable
(内网/离线备选:npm i -g git+https://gitea.inkreach.cc/inkreachhub/inkreach-teable-cli.git)
2. 安装技能:
npx -y skills add https://gitea.inkreach.cc/inkreachhub/inkreach-teable-cli.git -g -y --copy
3. 鉴权(PAT 从 Web UI Settings → Personal Access Tokens 获取):
inkreach-teable auth login --endpoint https://table.inkreach.cc --token <PAT> --base-id bseXXX
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
SQL(只读):
sql <sql> POST /api/base/{baseId}/sql
~~sql <sql>~~ (removed in 0.1.5 — backend has no SQL query API)
```
### 3.3 关键设计决策
@@ -170,7 +170,7 @@ YAML frontmatter(name + 极详细的 description 触发条件)+ 以下章节
2. **基础资源命令**:base/table/field/record/view(官方兼容端点,最简单)。
3. **workflow 命令**:list/get/create/update/delete/activate/deactivate/test。
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。
7. **端到端验证**:真实实例跑通全链路。
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()
@@ -48,6 +48,39 @@
"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": "我想大概每天知道一下订单进展。邮件先不用,站内通知就行。你看着帮我弄一个。"
}
]
}
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@inkreach/inkreach-teable",
"version": "0.1.1",
"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).",
"license": "MIT",
"type": "commonjs",
@@ -26,7 +26,8 @@
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"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": {
"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
+27 -12
View File
@@ -23,18 +23,31 @@ rebuilding the host Next.js app. Plugins are IIFE bundles — they ship as plain
JS that executes in the host page and accesses host APIs through
`window.__TEABLE_HOST__`.
> **Contract**: A plugin = a webpack IIFE bundle (`dist/main.js` + optional
> `dist/styles.css`) + `plugin.json` manifest, uploaded to the host via
> `POST /api/plugin/:id/upload-bundle`. It runs in the same React tree as the host
> and has full access to the host's SDK, UI library, and OpenAPI helpers.
> **Contract**: A plugin = a webpack (or vite) IIFE bundle (`dist/main.js` +
> optional lazy chunks `*.chunk.js`, source maps `*.js.map`, `styles.css`) +
> a `plugin.json` manifest, uploaded to the host via
> `POST /api/plugin/:id/upload-bundle` (or `teable-plugin deploy`, which
> handles all assets). It runs in the same React tree as the host and has
> full access to the host's SDK, UI library, and OpenAPI helpers.
>
> **Dev loop**: `teable-plugin dev` (or `pnpm dev` in scaffolded projects)
> watch-builds and auto-uploads with a `devMode` manifest; the open host page
> remounts the plugin within ~4s without a page refresh. See
> `dynamic-plugins/dev-workflow.md`.
>
> **Full-page apps**: install at position `route` and the host serves the
> plugin on `/base/<baseId>/p/<pluginId>` (whole viewport, own router OK —
> HashRouter recommended).
>
> **Critical constraint**: no RUNTIME imports from `@teable/sdk`, `@teable/ui-lib`,
> `@teable/openapi` — webpack maps them to `window.__TEABLE_HOST__.*` externals, so a
> value import compiles to `undefined` at runtime. `import type { X } from '@teable/...'`
> IS fine (erased at compile time). `react`/`react-dom` may be imported ONLY when your
> webpack `externals` maps them to `window.__TEABLE_HOST__.React/ReactDOM` (see the
> batch-edit-pro webpack config). For everything else, read from the host global:
> `const { useFields } = window.__TEABLE_HOST__.SDK`. See
> `@teable/openapi` — a value import via monorepo aliases inlines the whole
> package source (via window-global externals it compiles to `undefined`).
> `import type { X } from '@teable/...'` IS fine (erased at compile time).
> `react`/`react-dom` may be imported when your webpack `externals` maps them
> to `window.__TEABLE_HOST__.React/ReactDOM` (see the plugin-showcase webpack
> config; `react/jsx-runtime` is intentionally bundled — element factories
> only, hooks stay on the host singleton). For everything else, read from the
> host global: `const { useFields } = window.__TEABLE_HOST__.SDK`. See
> `dynamic-plugins/troubleshooting.md` Pitfall 1 for details and symptoms.
## 0. Two efficiency rules (read first)
@@ -69,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 |
| 2. Dev workflow | `dynamic-plugins/dev-workflow.md` | when building a plugin — the step-by-step recipe from scaffold to deploy |
| 3. SDK API | `dynamic-plugins/sdk-api.md` | when writing plugin logic — definePlugin, PluginContext, events, storage, useSelection, native form embedding (`NativeForm` Tier-1 injection / `NativeFormScope`+`useNativeForm` Tier-2 kit), NativeGrid |
| 3b. Native form quick answer | `dynamic-plugins/native-form.md` | **when the user ASKS whether/how to embed native field editors, custom form layout, or multi-step forms — read this FIRST and answer directly (consultative questions need no scaffold/build)** |
| 4. Deployment | `dynamic-plugins/deployment.md` | when uploading + enabling + authority matrix permissions |
| 5. Troubleshooting | `dynamic-plugins/troubleshooting.md` | before writing logic — common pitfalls (host globals, developer whitelist, AM, empty states) |
| Ref: batch-edit-pro | `plugins/batch-edit-pro/` | the canonical example — contextMenu plugin, full React + SDK |
| 5. Troubleshooting | `dynamic-plugins/troubleshooting.md` | before writing logic — common pitfalls (host globals, developer whitelist, AM, empty states, chunk-name flattening, terser banners) |
| Ref: plugin-showcase | `plugins/plugin-showcase/` | the v2 example — lazy chunks + source maps + Tailwind + HashRouter route app |
| Ref: batch-edit-pro | `plugins/batch-edit-pro/` | the classic example — single-file contextMenu plugin, full React + SDK |
## 3. Quick decision: which position?
@@ -7,9 +7,18 @@ Uploading a bundle plugin, enabling it, and managing permissions.
Upload the built bundle to the host. Uses `multipart/form-data`.
The `pluginId` in the URL must match the `id` in `plugin.json`.
Preferred (handles entry + chunks + maps + css automatically):
```bash
TEABLE_ENDPOINT=http://localhost:3003 TEABLE_PAT=<pat> \
node /root/deployments/inkreach-teable/packages/plugin-cli/dist/index.js deploy -d .
```
Raw curl equivalent (legacy single-file form still works):
```bash
PLUGIN_ID=$(python3 -c "import json; print(json.load(open('plugin.json'))['id'])")
curl -s -b "auth_session=$SESSION" \
curl -s -H "Authorization: Bearer $TEABLE_PAT" \
-F "js=@dist/main.js" \
-F "css=@dist/styles.css" \
-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
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:**
| 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 |
**Fields** (v2 multi-asset, since 2026-10):
**Returns** `201` with `{ id, name, version, ... }` — the plugin ID to use for
enable and install.
| Field | Required | Max count | Max size | Description |
|---|---|---|---|---|
| `js` | Yes | 20 | 5 MB each | Entry + lazy chunks. Entry = the file whose name matches `manifest.entry` (a lone differently-named js file still works — legacy fallback) |
| `css` | No | 5 | 1 MB each | Stylesheets, concatenated under the `manifest.style` name |
| `map` | No | 20 | 5 MB each | Source maps, served later as `application/json` |
| `manifest` | Yes | 1 | 10 KB | `plugin.json` manifest |
- **Total cap 15 MB** across all assets.
- Chunk filenames must be **FLAT** (`x-<hash>.chunk.js`) — the multipart
transport strips directory parts from filenames.
- The bundle dir on disk (`<backend-cwd>/.assets/plugins/bundle/<pluginId>/`)
is **wiped on every upload** — stale chunks are removed, and hand-patched
files on disk are discarded.
**Returns** `{ pluginId, hash, size, assets }` — `assets` is the stored file
count (1 for legacy single-file uploads).
**Registering a NEW plugin id**: `POST /api/plugin {"id": "<kebab-id>", ...}`
— PAT-accessible (`@TokenAccess()` since 2026-10) and **custom ids require
the `plugin_developer` whitelist**. Without an explicit `id` the server
mints a `plgXXX` id, which CANNOT receive bundles (manifest id must equal
the DB id). Deleting a plugin (`DELETE /api/plugin/:id`) remains admin-only.
**Security** (7-layer validation):
1. Authentication — valid session cookie 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)
3. Rate limit — 10 uploads/min per user (429 if exceeded)
4. Size limits — JS ≤ 5 MB, CSS ≤ 1 MB, manifest ≤ 10 KB
5. Manifest validation — required fields, `id` regex `[a-z0-9-]+`
6. Content validation — JS must be IIFE format (prefix `!function`/`(()=>`),
rejects `<script>`/`eval`/`document.write` injection; CSS strips `expression()`/
`@import`/`javascript:` URIs
7. Integrity — SHA-256 hash stored in DB, verified at load time
4. Size limits — JS/map ≤ 5 MB each, CSS ≤ 1 MB each, manifest ≤ 10 KB,
15 MB total
5. Manifest validation — required fields, `id` regex `[a-z0-9-]+`,
`minAppVersion` ≤ host version
6. Content validation — the ENTRY must be IIFE format (prefix `!function` /
`(function` / `(() =>` / `var <name>=<iife>`); chunks get the
forbidden-pattern check only (they are not IIFEs); JS rejects
`<script>`/`document.write`/`javascript:`; CSS strips `expression()`/
`@import`/`javascript:` URIs; maps are name/size checked only (they embed
original source text — pattern checks would false-positive)
7. Integrity — entry SHA-256 stored in DB for audit + PluginAuditLog row
(uploader/IP/UA); note the loader does NOT re-verify the hash at load time
## Enable — `PUT /api/base/:baseId/plugin-config/:pluginId`
@@ -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
denied for non-owner roles).
### Route
### Route (full-page app, since 2026-10)
Route-type plugins registered via `ctx.addRoute(path, component)` are
automatically available at the registered path — no install API needed.
Upload + enable is sufficient.
The host renders a dedicated page for the plugin at
`/base/<baseId>/p/<pluginId>` — the whole main-area viewport inside the base
layout. The plugin registers via `ctx.addRoute(id, component)` and typically
brings its own router (HashRouter recommended, so Next.js routing is
untouched).
```bash
curl -s -H "Authorization: Bearer $TEABLE_PAT" -X POST \
"http://localhost:3003/api/base/$BASE_ID/plugin-installs" \
-H 'Content-Type: application/json' \
-d '{"pluginId":"$PLUGIN_ID","name":"My App","position":"route"}'
```
Notes (verified 2026-10 on plugin-showcase):
- `manifest.positions` must include `"route"`.
- The page itself does not gate on plugin-config enablement — the URL is the
access point. Follow the standard enable+install flow anyway so the plugin
shows up in management UIs and future permission layers behave uniformly.
- Host SDK hooks (e.g. `host.SDK.useBaseId()`) work on the route page because
it renders inside BaseLayout's provider stack.
## Authority Matrix (plugin permissions)
@@ -167,23 +216,18 @@ BASE_ID="${BASE_ID:?set to a throwaway sandbox base, never a production base}"
# Read plugin ID from manifest
PLUGIN_ID=$(python3 -c "import json; print(json.load(open('$DIR/plugin.json'))['id'])")
# Get session
# PAT preferred (upload-bundle accepts Bearer auth):
AUTH=(-H "Authorization: Bearer ${PAT:?set PAT env}")
# ...or session:
SESSION=$(grep auth_session /tmp/cookie.txt 2>/dev/null | awk '{print $NF}')
AUTH=(-b "auth_session=$SESSION")
# Upload (URL includes pluginId from manifest)
curl -s "${AUTH[@]}" \
-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"
# Upload: teable-plugin deploy handles entry + chunks + maps + css.
# Raw-curl equivalent for a single-file bundle:
# curl -s -H "Authorization: Bearer $TEABLE_PAT" \
# -F "js=@$DIR/dist/main.js" -F "css=@$DIR/dist/styles.css" \
# -F "manifest=@$DIR/plugin.json" \
# "$UPLOAD_URL/api/plugin/$PLUGIN_ID/upload-bundle"
TEABLE_ENDPOINT="$UPLOAD_URL" TEABLE_PAT="${TEABLE_PAT:?set PAT env}" \
node /root/deployments/inkreach-teable/packages/plugin-cli/dist/index.js deploy -d "$DIR"
echo "Uploaded: $PLUGIN_ID"
# Enable
curl -s "${AUTH[@]}" -X PUT \
curl -s -H "Authorization: Bearer $TEABLE_PAT" -X PUT \
"$UPLOAD_URL/api/base/$BASE_ID/plugin-config/$PLUGIN_ID" \
-H 'Content-Type: application/json' \
-d '{"enabled":true,"tableIds":[]}' > /dev/null
@@ -1,18 +1,37 @@
# Plugin development workflow
The standard recipe for creating, building, and deploying a dynamic React
component plugin. Each plugin is a standalone webpack project that outputs
an IIFE bundle.
component plugin. Each plugin is a standalone webpack (or vite) project that
outputs an IIFE bundle. Since 2026-10 the pipeline is multi-asset: lazy
chunks, source maps, and multiple CSS files upload alongside the entry.
## Output contract
Every plugin task ends with:
1. A built IIFE bundle (`dist/main.js` + optional `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
3. Uploaded to the host via `POST /api/plugin/:id/upload-bundle`
(or `teable-plugin deploy` which handles all assets)
4. Enabled via `PUT /api/base/:baseId/plugin-config/:pluginId`
5. Installed to the target position (the install API varies by position)
## Dev loop (hot reload — preferred while iterating)
```bash
cd plugins/my-plugin # or wherever the project lives
TEABLE_ENDPOINT=http://localhost:3003 TEABLE_PAT=<pat> pnpm dev
# or: node ../../packages/plugin-cli/dist/index.js dev -d .
```
This runs `webpack --watch` (production mode — dev-mode output carries
`/******/` banners that fail the IIFE validator), auto-uploads on every
change with a `devMode: true, buildId: <ts>` manifest, and the open host
page remounts the plugin within ~4s WITHOUT a page refresh. Production
deploys (no devMode in manifest) never poll. Verified 2026-10-13 on
plugin-showcase: edit → ~5s live update, window markers survive (SPA-level
remount, not a reload).
## Step 1 — Understand the request
Clarify what the user wants, then pick the position using `overview.md`.
@@ -26,23 +45,33 @@ to npm). Run it directly:
```bash
cd /tmp
node /root/deployments/inkreach-teable/packages/plugin-cli/dist/index.js create my-plugin
# or the vite template:
node /root/deployments/inkreach-teable/packages/plugin-cli/dist/index.js create my-plugin --template vite
cd my-plugin
pnpm install
```
The scaffold adapts automatically: inside the monorepo it wires
`@teable/plugin-sdk: workspace:*` + source aliases; standalone projects get a
loose-typed local host module (the npm package is not published). Vite
scaffolds also generate a `pnpm-workspace.yaml` with `allowBuilds: esbuild`
(pnpm 11 fails `pnpm install` without it).
This generates:
```
my-plugin/
├── src/
│ └── index.tsx # entry: calls definePlugin()
│ ├── host.ts # typed host accessor
│ └── index.tsx # entry: definePlugin via host.PluginSDK
├── plugin.json # manifest
├── webpack.config.js # pre-configured externals
├── webpack.config.js # or vite.config.ts
├── tsconfig.json
└── package.json
└── package.json # dev/build/deploy scripts
```
If the CLI doesn't work for some reason, manually create the structure by
copying `plugins/batch-edit-pro/` as a template.
Reference implementations in the repo: `plugins/plugin-showcase/` (chunks +
sourcemap + Tailwind + HashRouter route app — the v2 showcase) and
`plugins/batch-edit-pro/` (classic single-file contextMenu plugin).
## Step 3 — Write the plugin code
@@ -117,59 +146,114 @@ definePlugin({
## 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
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:
```bash
ls -la dist/main.js dist/styles.css plugin.json
```js
// webpack.config.js — known-good v2 (from plugins/plugin-showcase/)
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const pluginId = require('./plugin.json').id;
module.exports = {
mode: 'production',
entry: './src/index.tsx',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'main.js',
// FLAT chunk names: multipart transport strips directory parts, so
// nested chunk paths would 404 at runtime.
chunkFilename: '[name]-[contenthash].chunk.js',
// required so React.lazy/import() chunks resolve from the host runtime
publicPath: `/api/plugin/${pluginId}/runtime/`,
clean: true,
// terser's minified "(()=>{" fails the validator's "(() =>" check
environment: { arrowFunction: false },
},
devtool: 'source-map', // maps upload + serve as application/json
// NO output.library — never add library.type: 'iife' (webpack rejects it)
externals: {
'react': 'window.__TEABLE_HOST__.React',
'react-dom': 'window.__TEABLE_HOST__.ReactDOM',
},
resolve: { extensions: ['.tsx', '.ts', '.js', '.jsx'] },
module: {
rules: [
{ test: /\.tsx?$/, loader: 'ts-loader', options: { transpileOnly: true }, exclude: /node_modules/ },
{ test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader'] },
],
},
optimization: {
minimizer: [
// license-comment extraction prepends a "/*!..." banner that breaks
// the IIFE-prefix check — keep comments inline
new TerserPlugin({ extractComments: false }),
],
},
plugins: [new MiniCssExtractPlugin({ filename: 'styles.css' })],
};
```
`react/jsx-runtime` is intentionally NOT external: the jsx automatic
transform bundles it and that is safe (element factories only — hooks still
come from the host singleton).
Vite template notes: `build.lib formats: ['iife']` + rollup externals with
`globals: { react: 'window.__TEABLE_HOST__.React', ... }`; output starts
`(function(...)` or `var <name>=(function...` — both pass the validator.
IIFE cannot code-split (dynamic imports inline) — use the webpack template
if you need lazy chunks.
If you build outside the monorepo, drop the `resolve.alias` block (it exists
only so TS can type-check `import type` from `@teable/*` sources) and use
`import type` + `as unknown as` casts instead of runtime imports.
Outputs: `dist/main.js` (IIFE) + optional `dist/*.chunk.js` (lazy chunks) +
`dist/*.js.map` + `dist/styles.css`.
## Step 5 — Upload the bundle
The upload URL includes the plugin's `id` from `plugin.json`. Extract it first,
then upload.
Preferred (handles entry + chunks + maps + css automatically):
```bash
SESSION=$(grep auth_session /tmp/cookie_hr.txt 2>/dev/null | awk '{print $NF}')
if [ -z "$SESSION" ]; then
SESSION=$(curl -s -c - http://localhost:3003/api/auth/signin \
-X POST -H 'Content-Type: application/json' \
-d '{"email":"hr_admin@xingchen.tech","password":"Xingchen@2026"}' \
| grep auth_session | awk '{print $NF}')
TEABLE_ENDPOINT=http://localhost:3003 TEABLE_PAT=<pat> \
node ../../packages/plugin-cli/dist/index.js deploy -d .
# or from the plugin dir: pnpm deploy
```
> 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).
fi
Raw curl equivalent (single-file legacy form still works):
```bash
PLUGIN_ID=$(python3 -c "import json; print(json.load(open('plugin.json'))['id'])")
curl -s -b "auth_session=$SESSION" \
curl -s -H "Authorization: Bearer $TEABLE_PAT" \
-F "js=@dist/main.js" \
-F "css=@dist/styles.css" \
-F "manifest=@plugin.json" \
"http://localhost:3003/api/plugin/$PLUGIN_ID/upload-bundle"
echo "PLUGIN_ID=$PLUGIN_ID"
```
- The plugin `id` in the URL must match the `id` in `plugin.json`. On first
upload, the plugin is created; on subsequent uploads, the existing plugin is
updated.
- Auth supports both session cookies (`auth_session`) and Personal Access Tokens
(`Authorization: Bearer <PAT>`).
- Returns 201 on success.
Multipart notes (v2, 2026-10):
- `js` field accepts up to 20 files (entry first or matched by manifest
entry filename), `css` up to 5, `map` up to 20.
- The plugin row must already exist (`POST /api/plugin {"id": "<kebab-id>"}`
— custom ids require the plugin_developer whitelist; the endpoint is
`@TokenAccess()` so a PAT works).
- Limits: 5MB per JS/map file, 1MB per CSS, 15MB total. The bundleDir is
WIPED before writing — stale chunks don't linger, but hand-patched files
on disk are discarded on the next upload.
- Auth supports session cookies (`auth_session`) and PATs
(`Authorization: Bearer`). Runtime GETs (`/api/plugin/:id/runtime/*`) are
session-only (PAT gets 403 by design).
## Step 6 — Enable (REQUIRED before install)
@@ -203,6 +287,11 @@ Open the relevant page and confirm the plugin renders and works:
- Panel: bottom-right Plugins button in a table view
- View: view-type dropdown
- Dashboard: space dashboard page
- Route (full-page app): `/base/<baseId>/p/<pluginId>` — install at position
`route`, the page renders the plugin full-screen inside BaseLayout (bring
your own router; HashRouter recommended so Next.js routing is untouched).
Verified flow: enable via plugin-config → install via
`POST /api/base/:baseId/plugin-installs {"position":"route"}` → open the URL.
Use `browser-act` to verify or ask the user to check.
@@ -210,14 +299,18 @@ Use `browser-act` to verify or ask the user to check.
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`
- [ ] No hardcoded URLs/table IDs/tokens — all via host SDK
- [ ] Loading, error, and empty states handled in the UI
- [ ] `definePlugin()` called with `onLoad(ctx)` that registers at least one component
- [ ] Upload returns 201, pluginId captured
- [ ] Upload returns 2xx (assets count > 1 when chunks/maps exist)
- [ ] Enable (plugin-config) called before install
- [ ] Install returns 2xx
- [ ] Plugin renders and works
- [ ] Dev-loop deploys redone as a production upload (no `devMode` in the
final manifest) before handover
If any box is unchecked, fix it.
@@ -0,0 +1,75 @@
# Native form embedding — quick answer
**问题:能不能在插件里做自定义布局的表单(多步表单/侧栏表单),每一步用系统
原生字段编辑器,不自己重写输入组件?**
**能。宿主暴露了完整的原生表单注入能力,共两档。咨询类问题到这里就有答案了,
不需要先去搭工程验证。**
| 需求 | 用哪档 | 核心 API(都在 `window.__TEABLE_HOST__`) |
|---|---|---|
| 整表单嵌入面板/视图,能裁剪字段、插自定义内容 | **Tier-1 整表注入** | `host.NativeForm` |
| 自己控制每行渲染、逐步表单、校验与暂存 | **Tier-2 逐字段套件** | `host.NativeFormScope` + `host.NativeFormField` + `host.useNativeForm()` |
## Tier-1:`<NativeForm>` 整表注入(最常用)
```typescript
const { NativeForm } = window.__TEABLE_HOST__;
function MyFormPanel() {
return (
<NativeForm
formId="viwIssueForm" // 一个 form 视图的 id
visibleFieldIds={['fldTitle', 'fldDesc']} // 裁剪+重排(仅会话级)
hiddenFieldIds={['fld_internalNote']} // 或只隐藏
slots={[ // 在任意位置插自定义内容
{ position: 'top', render: ({ values }) => <h3>提单</h3> },
]}
interceptSubmit={(fields) => fields} // 提交前改写载荷(原生提交与自动化照常)
/>
);
}
```
要点:原生提交、校验、`formSubmitted` 自动化全部照常;虚拟值放
`__virtual.<key>`,提交时自动剥离。
## Tier-2:多步表单(逐步切换字段、原生编辑器)
```typescript
const { NativeFormScope, NativeFormField, useNativeForm } = window.__TEABLE_HOST__;
const STEPS = [
['fldTitle', 'fldCustomer'],
['fldAmount', 'fldUrgency'],
['fldNotes'],
];
function Stepper({ formId }: { formId: string }) {
return (
<NativeFormScope formId={formId}> {/* 状态/L localStorage 暂存由 Scope 接管 */}
<StepBody formId={formId} />
</NativeFormScope>
);
}
function StepBody({ formId }: { formId: string }) {
const { values, setValue, errors, fields, step, setStep } = useNativeForm({ formId, steps: STEPS });
return (
<div>
{STEPS[step].map((fid) => (
<NativeFormField key={fid} fieldId={fid} /> {/* 100% 原生字段编辑器 */}
))}
<button onClick={() => setStep(step - 1)} disabled={step === 0}>上一步</button>
<button onClick={() => setStep(step + 1)} disabled={step === STEPS.length - 1}>下一步</button>
</div>
);
}
```
## 决策与边界
- 校验/错误提示:走原生(`errors` 由 hook 暴露);自定义校验放 `interceptSubmit`。
- `submitOverride` 会**绕过**原生提交 → `formSubmitted` 自动化不会触发(要触发就别用它)。
- computed/lookup 字段永远不可编辑(原生行为,注入也一样)。
- 详细 props 表与虚拟字段模式见 [sdk-api.md](sdk-api.md) 的 `NativeForm` / `NativeFormScope` 章节。
@@ -49,11 +49,17 @@ gated by a developer whitelist and bundles are validated for security.
→ check minAppVersion compatibility
→ <link> inject CSS (if manifest.style exists)
→ <script src="/api/plugin/:id/runtime/main.js"> (IIFE executes)
→ if the bundle code-splits, lazy chunks load from the same runtime
endpoint (output.publicPath = /api/plugin/:id/runtime/), served as
plain .js assets
→ script calls definePlugin({ id, name, onLoad, ... })
→ window.__TEABLE_PLUGINS__[pluginId] = config
→ PluginLoader reads registration, creates PluginContext, calls onLoad(ctx)
→ ctx.addContextMenuAction(...) etc. register React components to componentRegistry
→ PluginSlot renders registered components at the right mount point
→ if manifest.devMode is true (dev loop only), the loader polls the
manifest every 4s and remounts the plugin when buildId changes —
no page refresh (production manifests never set devMode)
3. Plugin unloads
→ onUnload(ctx) called
@@ -117,32 +123,41 @@ Location: `apps/nextjs-app/src/features/app/components/plugin/host/event-bus.ts`
```
dist/
├── main.js # IIFE bundle (webpack output)
└── styles.css # optional stylesheet
plugin.json # manifest
├── main.js # IIFE entry bundle (webpack/vite output)
├── <name>-<hash>.chunk.js # optional lazy chunks (code splitting) — FLAT names
├── *.js.map # optional source maps (served as application/json)
└── styles.css # optional stylesheet (Tailwind/SCSS output OK)
plugin.json # manifest
```
## Shared dependency bridging
Plugin webpack config marks these as externals, mapping to host globals:
Plugin webpack config marks ONLY the React singleton as external, mapping to
a host global:
```javascript
externals: {
'react': 'window.__TEABLE_HOST__.React',
'react-dom': 'window.__TEABLE_HOST__.ReactDOM',
'@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',
'react': 'window.__TEABLE_HOST__.React',
'react-dom': 'window.__TEABLE_HOST__.ReactDOM',
}
```
This means the plugin does NOT bundle React or host packages — it uses the
host's singleton instances. The bundle only contains the plugin's own code.
Everything else on the host (`SDK`, `UILib`, `OpenAPI`, `PluginSDK`) is read
from `window.__TEABLE_HOST__` at runtime — there is NO working externals
mapping for `@teable/*` packages (a value import via the old
window-global-externals style compiled to `undefined`; via monorepo source
aliases it would inline the whole package source — see troubleshooting.md
Pitfall 1). `react/jsx-runtime` is intentionally NOT external: the jsx
automatic transform bundles it, which is safe — it only creates element
objects, while hooks/context/state still resolve through the externalized
`react` → host singleton.
Other npm dependencies (react-router, charts, lodash…) are bundled into the
plugin as normal.
## Two ways to access host APIs
### 1. Through `window.__TEABLE_HOST__` (recommended for IIFE)
### 1. Through `window.__TEABLE_HOST__` (the only runtime path)
```javascript
const host = window.__TEABLE_HOST__;
@@ -153,17 +168,21 @@ const { getRecords } = host.OpenAPI;
const { definePlugin, useSelection } = host.PluginSDK;
```
This is what `plugins/batch-edit-pro/` uses — the plugin bundle has no
`@teable/*` imports, everything resolves through the host global.
This is what `plugins/plugin-showcase/` and `plugins/batch-edit-pro/` use —
the plugin bundle has no `@teable/*` runtime imports, everything resolves
through the host global. `import { useState } from 'react'` also works (the
externals mapping above redirects it to the host singleton).
### 2. Through `@teable/plugin-sdk` (also works because webpack externals)
### 2. `import type` from `@teable/*` (type-checking only)
```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`.
Both styles work — the externals config handles the mapping.
`import type` is erased at compile time — it never reaches the bundle. This
is how scaffolded projects get full type safety inside the monorepo
(standalone scaffolds ship a loose-typed local host module instead).
## Positions and registration
@@ -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)` |
| View | View-type dropdown (alongside grid/form/gallery) | `ctx.addView(id, component)` |
| Dashboard | Grid card on space dashboard | `ctx.addDashboardWidget(id, component)` |
| Route | Full-page 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.addDashboardWidget(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
#### `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
`react-jsx` transform — JSX is auto-transpiled. But you still need
`const React = host.React` for any explicit `React.createElement` calls.
## Pitfall 8: Chunk 404 at runtime — multipart strips directory names (2026-10)
`webpack output.chunkFilename: 'static/js/[name].chunk.js'` builds fine, the
upload succeeds, but the browser 404s the chunk: multer/busboy flattens
multipart filenames, so the server stored `469.chunk.js` while the bundle
requests `static/js/469.chunk.js`.
**Fix**: keep chunk names FLAT — `chunkFilename: '[name]-[contenthash].chunk.js'`
(contenthash prevents collisions). Public path still works:
`output.publicPath: '/api/plugin/<id>/runtime/'`.
## Pitfall 9: `Bundle must be IIFE format` after a successful build
Three producer-side causes, all seen in the wild:
1. **Terser license banner** — bundling any npm package with license comments
makes webpack emit `/*! For license information... */` BEFORE `!function`.
Fix: `optimization.minimizer: [new TerserPlugin({ extractComments: false })]`.
2. **Development-mode webpack** output starts with `/******/` banner comments.
The dev watch loop therefore builds with `--mode production`.
3. **Vite/esbuild IIFE** output starts `var <name>=(function...` — the
validator accepts it (since 2026-10); no post-processing needed.
## Pitfall 10: vite scaffold `pnpm install` fails with ERR_PNPM_IGNORED_BUILDS
pnpm ≥ 10/11 blocks esbuild's postinstall and (on patched hosts) treats it as
fatal. Fix (scaffold generates it): `pnpm-workspace.yaml` with
`allowBuilds:\n esbuild: true`. esbuild also works without its script (binary
comes from optionalDependencies) — only the pnpm gate needs the allow entry.
## Pitfall 11: PAT gets 403 fetching `/api/plugin/:id/runtime/*`
Access tokens only pass routes with `@TokenAccess()` or `@Permissions` — the
runtime asset serving has neither. This is by design (the browser loads with a
session). For curl verification use a signed `auth_session` cookie; for uploads
use the PAT (`upload-bundle` and `POST /api/plugin` are token-accessible).
## Pitfall 12: "react/jsx-runtime would duplicate React" worry
It does not. The jsx automatic transform imports `react/jsx-runtime`, which is
bundled into the plugin on purpose: it only creates plain element objects.
Hooks/context/state go through the externalized `react` → host singleton. This
is exactly how the deployed ticket-console/batch-edit-pro bundles behave.
+3 -3
View File
@@ -1,7 +1,7 @@
{
"version": "1.0.0",
"version": "1.1.0",
"organization": "inkreach",
"authors": ["inkreach"],
"tags": ["plugin", "teable", "zip", "bridge", "context-menu", "dashboard", "panel", "view", "iframe"],
"abstract": "Develop ZIP plugins for inkreach-teable: 4 mount positions, penpal bridge API, AI-friendly templates and examples."
"tags": ["plugin", "teable", "bundle", "iife", "webpack", "vite", "context-menu", "dashboard", "panel", "view", "route", "hot-reload", "source-map"],
"abstract": "Develop dynamic React component plugins for inkreach-teable: IIFE bundles (webpack or vite) loaded at runtime via window.__TEABLE_HOST__ with 5 mount positions including full-page route apps, multi-asset uploads (lazy chunks + source maps), a watch-and-hot-reload dev loop, and the teable-plugin CLI scaffold."
}
+19 -5
View File
@@ -9,13 +9,20 @@ description: >-
Operate a deployed inkreach-teable instance via the `inkreach-teable` CLI.
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
> teable** — the official `teable` CLI corrupts or 404s on automations here.
> Exact key-name differences are in [Key Constraints](#3-key-constraints).
## 1. Prerequisites
- Install the CLI if missing: `npm i -g @inkreach/inkreach-teable` (public npm package). Alternatives: `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).
- `--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.
@@ -25,14 +32,13 @@ Respond in the user's language. Keep answers concise and action-oriented.
| 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) |
| 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) |
| 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) |
| 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) |
| Manual Trigger | Test button-click/webhook | `fire button-click`, `fire webhook` | [automation-guide.md § Testing](guides/automation-guide.md) |
| SQL | Read-only analytics, JOINs | `sql '<sql>'` | [cli-reference.md § sql](guides/cli-reference.md) |
| Direct REST API | Integrate external scripts/services; endpoints the CLI lacks; script actions calling back | `curl` / `fetch` + `Authorization: Bearer <PAT>` | [rest-api.md](api-reference/rest-api.md) |
| Plugins | Dynamic plugins, dev whitelist, CSP | `plugin *` | [cli-reference.md § plugin](guides/cli-reference.md) |
| Subscriptions | Record digest/instant email | `subscription *` | [subscription.md](guides/subscription.md) |
@@ -54,8 +60,9 @@ Full command syntax: [cli-reference.md](guides/cli-reference.md). Instance acces
| 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 |
| **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) |
| Read records for later writes | `record get` (returns record IDs) | `sql` (no record IDs) |
| 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 |
| 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) |
@@ -76,12 +83,17 @@ 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).
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`.
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
```bash
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 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 trigger set wflXXX --type buttonClick --table-id tblXXX --field-ids '["fldXXX"]'
inkreach-teable action add wflXXX --type script --code 'return triggerInput.record.fields'
@@ -103,4 +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` |
| 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 |
| `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 |
| `checkbox` | Checkbox | Yes/no toggle | No |
| `rating` | Rating | Star/numeric rating 1..10 | No (defaults: max=5, star, yellow) |
| `attachment` | Attachment | Files, images, documents | No |
| `user` | User | Person/collaborator reference | No |
| `attachment` | Attachment | Files, images, documents; cell values also accept `{"url":"https://…"}` items (server downloads the file) | No || `user` | User | Person/collaborator reference | No |
| `link` | Link | Reference records in another table | Yes (`foreignTableId`/`foreignTableName`) |
| `lookup` | Lookup | Mirror a field from linked record | Yes (`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"` |
| `link` (single) | `{"id":"recXXX"}` or `[{"id":"recXXX"}]` | `"Record Display Name"` |
| `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.
@@ -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
`key[]`): `filter` (JSON), `projection` (JSON array), `search[]` (keyword —
MUST be the `search[]` tuple form, plain `search=` is ignored), `skip` / `take`
(pagination; no `orderBy` on this fork — sort via view or `sql`). Response:
(pagination; no `orderBy` on this fork — sort via a view). Response:
`{ records: [{id, fields}], total? }`.
### Fields
@@ -83,7 +83,6 @@ MUST be the `search[]` tuple form, plain `search=` is ignored), `skip` / `take`
| 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…` | Stats family (group-points, calendar-daily, search-count…) — see swagger for params |
| `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.
6. To see the exact request the CLI sends for any operation, run it with
`--debug` (prints method/url/body) — the fastest way to learn a route.
7. **Attachment fields accept URLs** (2026-10): pass `{"url":"https://…","name":"…"}` items
(or a URL string with `typecast:true` — note `typecast` is a BODY key) in
record create/update `fields`, and the backend downloads the file
(SSRF-guarded, size-capped) into attachment storage — response items come
back with real `token`/`presignedUrl`. A failed download rejects the whole
request (400); nothing is silently dropped. V1 (legacy) bases need
`typecast:true` for the string form, V2 bases accept both. The dedicated
`POST /api/table/{tid}/record/{rid}/{fid}/uploadAttachment` (multipart,
`file` or `fileUrl`) is the older single-record path — the plain record
routes now cover URL imports.
## 5. Full Route Inventory (this guide ≠ the whole API)
+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`.
## 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
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>
# 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
inkreach-teable field get <tableId> <fieldIdOrName>
# 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>
# record
inkreach-teable record get --table-id <tid> [--filter '<json>'] [--projection '<json>'] [--page-size <n>] [--page <n>] [--search <kw>]
inkreach-teable record get-one <recordId>
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 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> --records '<json>' # batch
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
inkreach-teable view list <tableId>
@@ -181,18 +193,6 @@ inkreach-teable subscription send <subscriptionId>
Record digest/instant email subscriptions. Options (`--filter`, `--send-mode`,
`--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
Manage plugins and developer whitelist.
@@ -202,10 +202,14 @@ Manage plugins and developer whitelist.
inkreach-teable plugin list
# 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>]
# Bundle (upload) plugins use a different flow — see skill guides/tables-fields.md
# or api docs: create kebab-id plugin (developer whitelist) → upload → admin publish.
# Bundle (IIFE upload) plugins use a different flow — use the
# `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)
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
# install, or the install API returns 403 RESTRICTED_RESOURCE
# '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 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
```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>
```
@@ -70,10 +70,11 @@ inkreach-teable table delete <tableId>
```bash
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>'] \
[--page-size <n>] [--page <n>] [--search <kw>]
# Single record by ID
inkreach-teable record get-one <recordId>
# Single record by ID (table-scoped route)
inkreach-teable record get-one <recordId> --table-id <tid>
```
| 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
```
**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"]]}`
### 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.
**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
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"}'
```
**`--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
```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.
@@ -284,7 +311,7 @@ Max 1000 IDs per call. Irreversible.
```bash
inkreach-teable view list <tableId>
inkreach-teable view get <viewId>
inkreach-teable view get <viewId> --table-id <tid>
```
### Create
@@ -304,8 +331,8 @@ inkreach-teable view create --table-id <tid> --name "<name>" [--type <t>]
### Update (configure filters, sorts, grouping)
```bash
inkreach-teable view update <viewId> \
[--name "<n>"] \
inkreach-teable view update <viewId> --table-id <tid> \
[--name "<n>"] [--description "<d>"] \
[--filter '<json>'] \
[--sort '<json>'] \
[--group '<json>'] \
@@ -314,7 +341,7 @@ inkreach-teable view update <viewId> \
**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"}]`
@@ -323,7 +350,7 @@ inkreach-teable view update <viewId> \
### Delete
```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.
@@ -377,7 +404,7 @@ inkreach-teable preset delete <presetId> --table-id tblXxx
| `singleSelect` | `"Option Name"` | (string) |
| `multipleSelect` | `["Tag A","Tag B"]` | (array of strings) |
| `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"` |
| `link` | `[{"id":"recXXX"}]` | With `--typecast`: just `"Linked Record Name"` |
| `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 |
| 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 |
| 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 |
+1 -2
View File
@@ -10,7 +10,7 @@ import { pluginCommand } from './commands/plugin';
import { authCommand } from './commands/auth';
import { workflowCommand, triggerCommand, actionCommand } from './commands/workflow';
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';
const program = new Command();
@@ -42,7 +42,6 @@ program.addCommand(subscriptionCommand());
program.addCommand(secretCommand());
program.addCommand(runsCommand());
program.addCommand(fireCommand());
program.addCommand(sqlCommand());
program.parseAsync(process.argv).catch((err) => {
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 runs` — execution history.
* `inkreach-teable fire` — manual trigger testing (button-click / webhook).
* `inkreach-teable sql` — read-only SQL queries.
*/
import { Command } from 'commander';
import { run, requireBaseId } from '../lib/ctx';
@@ -282,49 +281,3 @@ export function fireCommand(): Command {
return cmd;
}
// ===================== sql =====================
export function sqlCommand(): Command {
const cmd = new Command('sql');
cmd.description('Execute a read-only SQL query (SELECT only)');
cmd.option('--base-id <id>', 'Base id (overrides default)');
cmd.argument('<sql>', 'SQL SELECT statement');
cmd.action(
run(async (ctx, args, o) => {
const sql = args[0] as string;
const opts = o as { baseId?: string };
const baseId = requireBaseId(ctx, opts.baseId);
const rows = (await ctx.client.sql(baseId, sql)) as unknown[];
if (ctx.json) {
printJson(rows);
} else {
if (!rows.length) {
process.stdout.write('No rows returned.\n');
return;
}
const first = rows[0];
if (typeof first === 'object' && first !== null && !Array.isArray(first)) {
const keys = Object.keys(first as Record<string, unknown>);
printTable(
keys.map((k) => ({ key: k, header: k })),
rows.map((r) => {
const obj = r as Record<string, unknown>;
const row: Record<string, unknown> = {};
for (const k of keys) {
row[k] = truncate(obj[k], 40);
}
return row;
})
);
process.stdout.write(`\n${rows.length} row(s).\n`);
} else {
rows.forEach((r) => process.stdout.write(JSON.stringify(r) + '\n'));
}
}
})
);
return cmd;
}
+302 -40
View File
@@ -20,6 +20,55 @@ import {
} from '../lib/field-normalize';
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;
* 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[] {
return [baseCmd(), tableCmd(), fieldCmd(), recordCmd(), viewCmd(), presetCmd()];
return [baseCmd(), spaceCmd(), tableCmd(), fieldCmd(), recordCmd(), viewCmd(), presetCmd()];
}
// ===================== 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
.command('get <baseId>')
.description('Get base details')
@@ -100,6 +178,54 @@ function baseCmd(): Command {
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 =====================
function tableCmd(): Command {
@@ -140,10 +266,12 @@ function tableCmd(): Command {
cmd
.command('get <tableId>')
.description('Get table details')
.option('--base-id <id>', 'Base id (overrides default)')
.action(
run(async (ctx, args) => {
run(async (ctx, args, o) => {
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);
else {
const t = table as Record<string, unknown>;
@@ -234,16 +362,18 @@ function fieldCmd(): Command {
cmd.description('Manage fields (columns) in a table');
cmd
.command('list <tableId>')
.command('list [tableId]')
.description('List fields in a table')
.option('--table-id <id>', 'Table id (alternative to the positional argument)')
.option(
'--with-options',
'Add an OPTIONS summary column and expand select choices with their visibility conditions'
)
.action(
run(async (ctx, args, o) => {
const opts = o as { withOptions?: boolean };
const tableId = args[0] as string;
const opts = o as { withOptions?: boolean; tableId?: 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>>;
if (ctx.json) printJson(list);
else {
@@ -303,13 +433,23 @@ function fieldCmd(): Command {
);
cmd
.command('get <tableId> <fieldIdOrName>')
.command('get [tableId] [fieldIdOrName]')
.description(
'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(
run(async (ctx, args) => {
const [tableId, fieldIdOrName] = args as [string, string];
run(async (ctx, args, o) => {
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);
if (ctx.json) printJson(field);
else {
@@ -469,12 +609,16 @@ function recordCmd(): Command {
cmd
.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')
.option('--record-id <id>', 'Get a single record by id (alternative to get-one)')
.option('--filter <json>', 'Filter conditions JSON')
.option('--projection <json>', 'Field projection JSON array')
.option('--page-size <n>', 'Records per page')
.option('--take <n>', 'Alias of --page-size')
.option('--page <n>', 'Page number (1-based)')
.option('--skip <n>', 'Records to skip (raw offset; overrides --page)')
.option('--search <kw>', 'Search keyword')
.option('--base-id <id>', 'Base id (overrides default)')
.action(
@@ -484,10 +628,22 @@ function recordCmd(): Command {
filter?: string;
projection?: string;
pageSize?: string;
take?: string;
page?: string;
skip?: string;
search?: 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,
// we follow the convention that base-scoped commands require baseId config)
requireBaseId(ctx, opts.baseId);
@@ -498,9 +654,12 @@ function recordCmd(): Command {
if (opts.search) query.search = opts.search;
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 (page) {
if (opts.skip) query.skip = parseInt(opts.skip, 10);
else if (page) {
const take = pageSize || 100;
query.skip = (page - 1) * take;
if (!pageSize) query.take = take;
@@ -543,10 +702,12 @@ function recordCmd(): Command {
cmd
.command('get-one <recordId>')
.description('Get a single record by id')
.requiredOption('--table-id <id>', 'Table id (record routes are table-scoped)')
.action(
run(async (ctx, args) => {
run(async (ctx, args, o) => {
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);
else {
const rec = record as { id?: string; fields?: Record<string, unknown> };
@@ -565,19 +726,50 @@ function recordCmd(): Command {
.requiredOption('--table-id <id>', 'Table id')
.option('--fields <json>', 'Fields object JSON, e.g. \'{"Title":"hello"}\'')
.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(
run(async (ctx, _args, o) => {
const opts = o as { tableId: string; fields?: string; records?: string };
let body: unknown;
const opts = o as {
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) {
body = { records: JSON.parse(opts.records) };
} 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 {
throw Object.assign(new Error('Provide --fields or --records'), {
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);
if (ctx.json) printJson(result);
else process.stdout.write(`✓ Record(s) created\n`);
@@ -592,10 +784,38 @@ function recordCmd(): Command {
'--records <json>',
'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(
run(async (ctx, _args, o) => {
const opts = o as { tableId: string; records: string };
const body = { records: JSON.parse(opts.records) };
const opts = o as {
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);
if (ctx.json) printJson(result);
else process.stdout.write(`✓ Records updated\n`);
@@ -603,17 +823,33 @@ function recordCmd(): Command {
);
cmd
.command('delete')
.command('delete [recordIds...]')
.description('Batch delete records by ids')
.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(
run(async (ctx, _args, o) => {
const opts = o as { tableId: string; recordIds: string };
const ids = opts.recordIds
.split(',')
.map((s) => s.trim())
.filter(Boolean);
run(async (ctx, args, o) => {
const opts = o as { tableId: string; recordIds?: string };
// 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(',')
.map((s) => s.trim())
.filter(Boolean)
: []),
...(args as unknown[]).flat().filter((x): x is string => typeof x === 'string'),
].flat()
),
];
if (!ids.length) {
throw Object.assign(new Error('Provide record ids (--record-ids or arguments)'), {
code: 'MISSING_RECORD_IDS',
});
}
const result = await ctx.client.deleteRecords(opts.tableId, ids);
if (ctx.json) printJson(result);
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
.command('list <tableId>')
.command('list [tableId]')
.description('List views in a table')
.option('--table-id <id>', 'Table id (alternative to the positional argument)')
.action(
run(async (ctx, args) => {
const tableId = args[0] as string;
run(async (ctx, args, o) => {
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>>;
if (ctx.json) printJson(list);
else {
@@ -661,10 +899,12 @@ function viewCmd(): Command {
cmd
.command('get <viewId>')
.description('Get view details')
.requiredOption('--table-id <id>', 'Table id (view routes are table-scoped)')
.action(
run(async (ctx, args) => {
run(async (ctx, args, o) => {
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);
else {
const v = view as Record<string, unknown>;
@@ -698,15 +938,34 @@ function viewCmd(): Command {
cmd
.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('--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(
run(async (ctx, args, o) => {
const viewId = args[0] as string;
const opts = o as { name?: string };
const body: Record<string, unknown> = {};
const opts = o as {
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;
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);
else process.stdout.write(`✓ View ${viewId} updated\n`);
})
@@ -715,10 +974,12 @@ function viewCmd(): Command {
cmd
.command('delete <viewId>')
.description('Delete a view')
.requiredOption('--table-id <id>', 'Table id (view routes are table-scoped)')
.action(
run(async (ctx, args) => {
run(async (ctx, args, o) => {
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 });
else process.stdout.write(`✓ View ${viewId} deleted\n`);
})
@@ -775,12 +1036,13 @@ function presetCmd(): Command {
);
cmd
.command('list <tableId>')
.command('list [tableId]')
.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')
.action(
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 list = (await ctx.client.listViewPresets(tableId, opts.viewId)) as Array<
Record<string, unknown>
+74 -22
View File
@@ -54,19 +54,36 @@ export class Client {
getBase(baseId: string) {
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 ----
listTables(baseId: string) {
return this.call<unknown[]>('GET', `/api/base/${baseId}/table`);
}
getTable(tableId: string) {
return this.call<unknown>('GET', `/api/table/${tableId}`);
getTable(tableId: string, baseId?: string) {
// 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) {
return this.call<unknown>('POST', `/api/base/${baseId}/table`, body);
}
updateTable(tableId: string, body: unknown) {
return this.call<unknown>('PUT', `/api/table/${tableId}`, body);
updateTable(tableId: string, body: { name: string }, baseId?: string) {
// 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) {
const base = baseId ?? this.cfg.baseId;
@@ -131,20 +148,29 @@ export class Client {
}
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() : '';
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) {
return this.call<unknown>('POST', `/api/table/${tableId}/record`, body);
createRecord(tableId: string, body: unknown, query?: Record<string, unknown>) {
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) {
return this.call<unknown>('PATCH', `/api/table/${tableId}/record`, body);
updateRecord(tableId: string, body: unknown, query?: Record<string, unknown>) {
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) {
return this.call<unknown>('PATCH', `/api/record/${recordId}`, body);
updateRecordOne(tableId: string, recordId: string, body: unknown, query?: Record<string, unknown>) {
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[]) {
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('&');
return this.call<unknown>('DELETE', `/api/table/${tableId}/record?${qs}`);
}
@@ -153,17 +179,47 @@ export class Client {
listViews(tableId: string) {
return this.call<unknown[]>('GET', `/api/table/${tableId}/view`);
}
getView(viewId: string) {
return this.call<unknown>('GET', `/api/view/${viewId}`);
getView(tableId: string, viewId: string) {
// 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) {
return this.call<unknown>('POST', `/api/table/${tableId}/view`, body);
}
updateView(viewId: string, body: unknown) {
return this.call<unknown>('PATCH', `/api/view/${viewId}`, body);
/** Update view parts — each key goes to its own sub-resource route. */
async updateView(
tableId: string,
viewId: string,
parts: {
name?: string;
description?: string;
filter?: unknown;
sort?: unknown;
group?: unknown;
columnMeta?: unknown;
options?: unknown;
}
): Promise<unknown> {
const base = `/api/table/${tableId}/view/${viewId}`;
let last: unknown;
if (parts.name !== undefined) last = await this.call('PUT', `${base}/name`, { name: parts.name });
if (parts.description !== undefined)
last = await this.call('PUT', `${base}/description`, { description: parts.description });
if (parts.filter !== undefined) last = await this.call('PUT', `${base}/filter`, { filter: parts.filter });
if (parts.sort !== undefined) {
// The API wants {sortObjs:[...]}; accept the natural bare-array form too.
const sort = Array.isArray(parts.sort) ? { sortObjs: parts.sort } : parts.sort;
last = await this.call('PUT', `${base}/sort`, { sort });
}
if (parts.group !== undefined) last = await this.call('PUT', `${base}/group`, { group: parts.group });
if (parts.columnMeta !== undefined)
last = await this.call('PUT', `${base}/column-meta`, parts.columnMeta);
if (parts.options !== undefined)
last = await this.call('PATCH', `${base}/options`, { options: parts.options });
return last;
}
deleteView(viewId: string) {
return this.call<{ id: string }>('DELETE', `/api/view/${viewId}`);
deleteView(tableId: string, viewId: string) {
return this.call<{ id: string }>('DELETE', `/api/table/${tableId}/view/${viewId}`);
}
// ---- view preset (self-built contract) ----
@@ -297,10 +353,6 @@ export class Client {
}
// ---- SQL (read-only) ----
sql(baseId: string, sql: string) {
return this.call<unknown[]>('POST', `/api/base/${baseId}/sql`, { sql });
}
// ---- plugin ----
getPlugins() {
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 { 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 {
client: Client;
json: boolean;
@@ -62,10 +92,12 @@ export function run<TArgs extends unknown[] = unknown[]>(
await fn(ctx, positional, options);
} catch (err) {
const { status, code, message } = describeError(err);
const hint = status ? buildHint(status, message) : undefined;
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 {
process.stderr.write(`✗ ${code}${status ? ` (${status})` : ''}: ${message}\n`);
if (hint) process.stderr.write(` hint: ${hint}\n`);
}
process.exitCode = 1;
}