Compare commits
10
Commits
69f354c833
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4462475a6f | ||
|
|
a3fa01e25f | ||
|
|
c9694511a1 | ||
|
|
517dde9dee | ||
|
|
d6a8bc4063 | ||
|
|
e584b7ebbe | ||
|
|
8dacbf7f2d | ||
|
|
fbff682d2c | ||
|
|
8654cd8f20 | ||
|
|
fbf97a1b46 |
@@ -1,15 +1,40 @@
|
|||||||
# inkreach-teable-cli
|
# inkreach-teable-cli
|
||||||
|
|
||||||
把下面这段引导词发给任意机器 / 任意会话中的 Agent,即可完成安装并开始使用:
|
inkreach-teable 实例的运维 CLI(npm 包 `@inkreach/inkreach-teable`)与
|
||||||
|
**全部 Agent Skills 的唯一维护仓库**。
|
||||||
|
|
||||||
|
## Skills(本仓库 `skills/` 目录 = single source of truth)
|
||||||
|
|
||||||
|
| Skill | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `inkreach-teable` | 操作已部署实例:表格/字段/记录/视图/自动化日常运维、REST API 参考 |
|
||||||
|
| `inkreach-teable-plugin` | 开发动态 React 组件插件:IIFE bundle(webpack/vite 双模板)、多资产上传(chunk + sourcemap)、`teable-plugin dev` 热更新闭环、5 个挂载位(含整页 route) |
|
||||||
|
|
||||||
|
**维护规则**:所有 skill 只在本仓库修改;主仓库(inkreach-teable)不含任何
|
||||||
|
skill 源文件。改完后同步到安装位置(`~/.zcode/skills/` 为主,
|
||||||
|
`~/.agents/skills/` 为镜像):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp -r skills/<name>/SKILL.md skills/<name>/metadata.json skills/<name>/dynamic-plugins ~/.zcode/skills/<name>/
|
||||||
|
cp -r skills/<name>/ ~/.agents/skills/<name>/ # 按需镜像
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安装(任意机器 / 任意会话的 Agent)
|
||||||
|
|
||||||
|
把下面这段引导词发给 Agent 即可完成安装并开始使用:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
请安装并用 inkreach-teable CLI 操作 inkreach-teable:
|
请安装并用 inkreach-teable CLI 操作 inkreach-teable:
|
||||||
1. 安装 cli(npm 公共包):
|
1. 安装 cli:
|
||||||
npm i -g @inkreach/inkreach-teable
|
npm i -g @inkreach/inkreach-teable
|
||||||
(内网/离线备选:npm i -g git+https://gitea.inkreach.cc/inkreachhub/inkreach-teable-cli.git)
|
|
||||||
2. 安装技能:
|
2. 安装技能:
|
||||||
npx -y skills add https://gitea.inkreach.cc/inkreachhub/inkreach-teable-cli.git -g -y --copy
|
npx -y skills add https://gitea.inkreach.cc/inkreachhub/inkreach-teable-cli.git -g -y --copy
|
||||||
3. 鉴权(PAT 从 Web UI Settings → Personal Access Tokens 获取):
|
3. 鉴权(PAT 从 Web UI Settings → Personal Access Tokens 获取):
|
||||||
inkreach-teable auth login --endpoint https://table.inkreach.cc --token <PAT> --base-id bseXXX
|
inkreach-teable auth login --endpoint https://table.inkreach.cc --token <PAT> --base-id bseXXX
|
||||||
inkreach-teable auth status
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
- `skills/` — Agent Skills(本仓库的核心资产之一)
|
||||||
|
- `src/` — CLI 源码(commander 实现)
|
||||||
|
- `docs/cli-skill-design.md` — CLI + Skill 的设计文档
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ inkreach-teable [全局选项] <命令>
|
|||||||
fire webhook <baseId> <workflowId> [--body <json>] 模拟 webhook
|
fire webhook <baseId> <workflowId> [--body <json>] 模拟 webhook
|
||||||
|
|
||||||
SQL(只读):
|
SQL(只读):
|
||||||
sql <sql> POST /api/base/{baseId}/sql
|
~~sql <sql>~~ (removed in 0.1.5 — backend has no SQL query API)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.3 关键设计决策
|
### 3.3 关键设计决策
|
||||||
@@ -170,7 +170,7 @@ YAML frontmatter(name + 极详细的 description 触发条件)+ 以下章节
|
|||||||
2. **基础资源命令**:base/table/field/record/view(官方兼容端点,最简单)。
|
2. **基础资源命令**:base/table/field/record/view(官方兼容端点,最简单)。
|
||||||
3. **workflow 命令**:list/get/create/update/delete/activate/deactivate/test。
|
3. **workflow 命令**:list/get/create/update/delete/activate/deactivate/test。
|
||||||
4. **触发器/Action 便捷封装**:`trigger set` / `action add/update/delete`(内部 GET-modify-PUT 全图)。
|
4. **触发器/Action 便捷封装**:`trigger set` / `action add/update/delete`(内部 GET-modify-PUT 全图)。
|
||||||
5. **secrets / runs / fire / sql**。
|
5. **secrets / runs / fire**(sql 已在 0.1.5 移除:后端无 SQL 查询 API)。
|
||||||
6. **skill 文档**:SKILL.md + guides + api-reference + metadata + install.sh。
|
6. **skill 文档**:SKILL.md + guides + api-reference + metadata + install.sh。
|
||||||
7. **端到端验证**:真实实例跑通全链路。
|
7. **端到端验证**:真实实例跑通全链路。
|
||||||
8. **注册**:在 AGENTS.md 添加 skill 说明。
|
8. **注册**:在 AGENTS.md 添加 skill 说明。
|
||||||
|
|||||||
@@ -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)
|
||||||
Binary file not shown.
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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)。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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",
|
"name": "weekday-schedule-trap",
|
||||||
"prompt": "我要一个自动化(名字:工作日盘点{TAG}),每个工作日下午5点跑一次,统计 order_logs 表的记录条数就行。帮我配好。",
|
"prompt": "我要一个自动化(名字:工作日盘点{TAG}),每个工作日下午5点跑一次,统计 order_logs 表的记录条数就行。帮我配好。",
|
||||||
"expected_output": "知道后端只认 timing.type+interval:要么明确告知『具体时刻/星期』不被支持并给出周期间隔替代方案;绝不悄悄配 weekdays/triggerTime 假装生效。timing 配置无被忽略字段。"
|
"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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@inkreach/inkreach-teable",
|
"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).",
|
"description": "CLI (command: inkreach-teable) for operating inkreach-teable: tables, fields, records, views, and the self-built automation engine (8 triggers + 7 actions).",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
@@ -26,7 +26,8 @@
|
|||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"clean": "rimraf dist tsconfig.build.tsbuildinfo",
|
"clean": "rimraf dist tsconfig.build.tsbuildinfo",
|
||||||
"prepare": "npm run build"
|
"prepare": "npm run build",
|
||||||
|
"prepublishOnly": "npm run build && npm test && bash scripts/smoke-commands.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"commander": "^12.1.0"
|
"commander": "^12.1.0"
|
||||||
|
|||||||
Executable
+263
@@ -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
|
||||||
Executable
+13
@@ -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
|
||||||
@@ -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
|
JS that executes in the host page and accesses host APIs through
|
||||||
`window.__TEABLE_HOST__`.
|
`window.__TEABLE_HOST__`.
|
||||||
|
|
||||||
> **Contract**: A plugin = a webpack IIFE bundle (`dist/main.js` + optional
|
> **Contract**: A plugin = a webpack (or vite) IIFE bundle (`dist/main.js` +
|
||||||
> `dist/styles.css`) + `plugin.json` manifest, uploaded to the host via
|
> optional lazy chunks `*.chunk.js`, source maps `*.js.map`, `styles.css`) +
|
||||||
> `POST /api/plugin/:id/upload-bundle`. It runs in the same React tree as the host
|
> a `plugin.json` manifest, uploaded to the host via
|
||||||
> and has full access to the host's SDK, UI library, and OpenAPI helpers.
|
> `POST /api/plugin/:id/upload-bundle` (or `teable-plugin deploy`, which
|
||||||
|
> handles all assets). It runs in the same React tree as the host and has
|
||||||
|
> full access to the host's SDK, UI library, and OpenAPI helpers.
|
||||||
|
>
|
||||||
|
> **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`,
|
> **Critical constraint**: no RUNTIME imports from `@teable/sdk`, `@teable/ui-lib`,
|
||||||
> `@teable/openapi` — webpack maps them to `window.__TEABLE_HOST__.*` externals, so a
|
> `@teable/openapi` — a value import via monorepo aliases inlines the whole
|
||||||
> value import compiles to `undefined` at runtime. `import type { X } from '@teable/...'`
|
> package source (via window-global externals it compiles to `undefined`).
|
||||||
> IS fine (erased at compile time). `react`/`react-dom` may be imported ONLY when your
|
> `import type { X } from '@teable/...'` IS fine (erased at compile time).
|
||||||
> webpack `externals` maps them to `window.__TEABLE_HOST__.React/ReactDOM` (see the
|
> `react`/`react-dom` may be imported when your webpack `externals` maps them
|
||||||
> batch-edit-pro webpack config). For everything else, read from the host global:
|
> to `window.__TEABLE_HOST__.React/ReactDOM` (see the plugin-showcase webpack
|
||||||
> `const { useFields } = window.__TEABLE_HOST__.SDK`. See
|
> config; `react/jsx-runtime` is intentionally bundled — element factories
|
||||||
|
> only, hooks stay on the host singleton). For everything else, read from the
|
||||||
|
> host global: `const { useFields } = window.__TEABLE_HOST__.SDK`. See
|
||||||
> `dynamic-plugins/troubleshooting.md` Pitfall 1 for details and symptoms.
|
> `dynamic-plugins/troubleshooting.md` Pitfall 1 for details and symptoms.
|
||||||
|
|
||||||
## 0. Two efficiency rules (read first)
|
## 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 |
|
| 1. Understand architecture | `dynamic-plugins/overview.md` | always first — how `window.__TEABLE_HOST__`, PluginLoader, PluginSlot work |
|
||||||
| 2. Dev workflow | `dynamic-plugins/dev-workflow.md` | when building a plugin — the step-by-step recipe from scaffold to deploy |
|
| 2. Dev workflow | `dynamic-plugins/dev-workflow.md` | when building a plugin — the step-by-step recipe from scaffold to deploy |
|
||||||
| 3. SDK API | `dynamic-plugins/sdk-api.md` | when writing plugin logic — definePlugin, PluginContext, events, storage, useSelection, native form embedding (`NativeForm` Tier-1 injection / `NativeFormScope`+`useNativeForm` Tier-2 kit), NativeGrid |
|
| 3. SDK API | `dynamic-plugins/sdk-api.md` | when writing plugin logic — definePlugin, PluginContext, events, storage, useSelection, native form embedding (`NativeForm` Tier-1 injection / `NativeFormScope`+`useNativeForm` Tier-2 kit), NativeGrid |
|
||||||
|
| 3b. Native form quick answer | `dynamic-plugins/native-form.md` | **when the user ASKS whether/how to embed native field editors, custom form layout, or multi-step forms — read this FIRST and answer directly (consultative questions need no scaffold/build)** |
|
||||||
| 4. Deployment | `dynamic-plugins/deployment.md` | when uploading + enabling + authority matrix permissions |
|
| 4. Deployment | `dynamic-plugins/deployment.md` | when uploading + enabling + authority matrix permissions |
|
||||||
| 5. Troubleshooting | `dynamic-plugins/troubleshooting.md` | before writing logic — common pitfalls (host globals, developer whitelist, AM, empty states) |
|
| 5. Troubleshooting | `dynamic-plugins/troubleshooting.md` | before writing logic — common pitfalls (host globals, developer whitelist, AM, empty states, chunk-name flattening, terser banners) |
|
||||||
| Ref: batch-edit-pro | `plugins/batch-edit-pro/` | the canonical example — contextMenu plugin, full React + SDK |
|
| Ref: plugin-showcase | `plugins/plugin-showcase/` | the v2 example — lazy chunks + source maps + Tailwind + HashRouter route app |
|
||||||
|
| Ref: batch-edit-pro | `plugins/batch-edit-pro/` | the classic example — single-file contextMenu plugin, full React + SDK |
|
||||||
|
|
||||||
## 3. Quick decision: which position?
|
## 3. Quick decision: which position?
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,18 @@ Uploading a bundle plugin, enabling it, and managing permissions.
|
|||||||
Upload the built bundle to the host. Uses `multipart/form-data`.
|
Upload the built bundle to the host. Uses `multipart/form-data`.
|
||||||
The `pluginId` in the URL must match the `id` in `plugin.json`.
|
The `pluginId` in the URL must match the `id` in `plugin.json`.
|
||||||
|
|
||||||
|
Preferred (handles entry + chunks + maps + css automatically):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TEABLE_ENDPOINT=http://localhost:3003 TEABLE_PAT=<pat> \
|
||||||
|
node /root/deployments/inkreach-teable/packages/plugin-cli/dist/index.js deploy -d .
|
||||||
|
```
|
||||||
|
|
||||||
|
Raw curl equivalent (legacy single-file form still works):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PLUGIN_ID=$(python3 -c "import json; print(json.load(open('plugin.json'))['id'])")
|
PLUGIN_ID=$(python3 -c "import json; print(json.load(open('plugin.json'))['id'])")
|
||||||
curl -s -b "auth_session=$SESSION" \
|
curl -s -H "Authorization: Bearer $TEABLE_PAT" \
|
||||||
-F "js=@dist/main.js" \
|
-F "js=@dist/main.js" \
|
||||||
-F "css=@dist/styles.css" \
|
-F "css=@dist/styles.css" \
|
||||||
-F "manifest=@plugin.json" \
|
-F "manifest=@plugin.json" \
|
||||||
@@ -17,28 +26,51 @@ curl -s -b "auth_session=$SESSION" \
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Auth note**: Supports both session cookies (`auth_session`) and Personal Access
|
**Auth note**: Supports both session cookies (`auth_session`) and Personal Access
|
||||||
Tokens (`Authorization: Bearer <PAT>`).
|
Tokens (`Authorization: Bearer <PAT>`). Runtime asset GETs
|
||||||
|
(`/api/plugin/:id/runtime/*`) are session-only (PAT gets 403 by design) —
|
||||||
|
verify those with a signed cookie, not a PAT.
|
||||||
|
|
||||||
**Fields:**
|
**Fields** (v2 multi-asset, since 2026-10):
|
||||||
| Field | Required | Max size | Description |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `js` | Yes | 5 MB | The IIFE bundle (`dist/main.js`) |
|
|
||||||
| `css` | No | 1 MB | Optional stylesheet (`dist/styles.css`) |
|
|
||||||
| `manifest` | Yes | 10 KB | `plugin.json` manifest |
|
|
||||||
|
|
||||||
**Returns** `201` with `{ id, name, version, ... }` — the plugin ID to use for
|
| Field | Required | Max count | Max size | Description |
|
||||||
enable and install.
|
|---|---|---|---|---|
|
||||||
|
| `js` | Yes | 20 | 5 MB each | Entry + lazy chunks. Entry = the file whose name matches `manifest.entry` (a lone differently-named js file still works — legacy fallback) |
|
||||||
|
| `css` | No | 5 | 1 MB each | Stylesheets, concatenated under the `manifest.style` name |
|
||||||
|
| `map` | No | 20 | 5 MB each | Source maps, served later as `application/json` |
|
||||||
|
| `manifest` | Yes | 1 | 10 KB | `plugin.json` manifest |
|
||||||
|
|
||||||
|
- **Total cap 15 MB** across all assets.
|
||||||
|
- Chunk filenames must be **FLAT** (`x-<hash>.chunk.js`) — the multipart
|
||||||
|
transport strips directory parts from filenames.
|
||||||
|
- The bundle dir on disk (`<backend-cwd>/.assets/plugins/bundle/<pluginId>/`)
|
||||||
|
is **wiped on every upload** — stale chunks are removed, and hand-patched
|
||||||
|
files on disk are discarded.
|
||||||
|
|
||||||
|
**Returns** `{ pluginId, hash, size, assets }` — `assets` is the stored file
|
||||||
|
count (1 for legacy single-file uploads).
|
||||||
|
|
||||||
|
**Registering a NEW plugin id**: `POST /api/plugin {"id": "<kebab-id>", ...}`
|
||||||
|
— PAT-accessible (`@TokenAccess()` since 2026-10) and **custom ids require
|
||||||
|
the `plugin_developer` whitelist**. Without an explicit `id` the server
|
||||||
|
mints a `plgXXX` id, which CANNOT receive bundles (manifest id must equal
|
||||||
|
the DB id). Deleting a plugin (`DELETE /api/plugin/:id`) remains admin-only.
|
||||||
|
|
||||||
**Security** (7-layer validation):
|
**Security** (7-layer validation):
|
||||||
1. Authentication — valid session cookie required (401 if missing)
|
1. Authentication — valid session cookie or PAT required (401 if missing)
|
||||||
2. Developer whitelist — `plugin_developer` table lookup (403 if not whitelisted)
|
2. Developer whitelist — `plugin_developer` table lookup (403 if not whitelisted)
|
||||||
3. Rate limit — 10 uploads/min per user (429 if exceeded)
|
3. Rate limit — 10 uploads/min per user (429 if exceeded)
|
||||||
4. Size limits — JS ≤ 5 MB, CSS ≤ 1 MB, manifest ≤ 10 KB
|
4. Size limits — JS/map ≤ 5 MB each, CSS ≤ 1 MB each, manifest ≤ 10 KB,
|
||||||
5. Manifest validation — required fields, `id` regex `[a-z0-9-]+`
|
15 MB total
|
||||||
6. Content validation — JS must be IIFE format (prefix `!function`/`(()=>`),
|
5. Manifest validation — required fields, `id` regex `[a-z0-9-]+`,
|
||||||
rejects `<script>`/`eval`/`document.write` injection; CSS strips `expression()`/
|
`minAppVersion` ≤ host version
|
||||||
`@import`/`javascript:` URIs
|
6. Content validation — the ENTRY must be IIFE format (prefix `!function` /
|
||||||
7. Integrity — SHA-256 hash stored in DB, verified at load time
|
`(function` / `(() =>` / `var <name>=<iife>`); chunks get the
|
||||||
|
forbidden-pattern check only (they are not IIFEs); JS rejects
|
||||||
|
`<script>`/`document.write`/`javascript:`; CSS strips `expression()`/
|
||||||
|
`@import`/`javascript:` URIs; maps are name/size checked only (they embed
|
||||||
|
original source text — pattern checks would false-positive)
|
||||||
|
7. Integrity — entry SHA-256 stored in DB for audit + PluginAuditLog row
|
||||||
|
(uploader/IP/UA); note the loader does NOT re-verify the hash at load time
|
||||||
|
|
||||||
## Enable — `PUT /api/base/:baseId/plugin-config/:pluginId`
|
## Enable — `PUT /api/base/:baseId/plugin-config/:pluginId`
|
||||||
|
|
||||||
@@ -112,11 +144,28 @@ must call `ctx.addView(id, component)` to render the component as a view.
|
|||||||
Authority Matrix entries are auto-created for the new install (default
|
Authority Matrix entries are auto-created for the new install (default
|
||||||
denied for non-owner roles).
|
denied for non-owner roles).
|
||||||
|
|
||||||
### Route
|
### Route (full-page app, since 2026-10)
|
||||||
|
|
||||||
Route-type plugins registered via `ctx.addRoute(path, component)` are
|
The host renders a dedicated page for the plugin at
|
||||||
automatically available at the registered path — no install API needed.
|
`/base/<baseId>/p/<pluginId>` — the whole main-area viewport inside the base
|
||||||
Upload + enable is sufficient.
|
layout. The plugin registers via `ctx.addRoute(id, component)` and typically
|
||||||
|
brings its own router (HashRouter recommended, so Next.js routing is
|
||||||
|
untouched).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -H "Authorization: Bearer $TEABLE_PAT" -X POST \
|
||||||
|
"http://localhost:3003/api/base/$BASE_ID/plugin-installs" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"pluginId":"$PLUGIN_ID","name":"My App","position":"route"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes (verified 2026-10 on plugin-showcase):
|
||||||
|
- `manifest.positions` must include `"route"`.
|
||||||
|
- The page itself does not gate on plugin-config enablement — the URL is the
|
||||||
|
access point. Follow the standard enable+install flow anyway so the plugin
|
||||||
|
shows up in management UIs and future permission layers behave uniformly.
|
||||||
|
- Host SDK hooks (e.g. `host.SDK.useBaseId()`) work on the route page because
|
||||||
|
it renders inside BaseLayout's provider stack.
|
||||||
|
|
||||||
## Authority Matrix (plugin permissions)
|
## Authority Matrix (plugin permissions)
|
||||||
|
|
||||||
@@ -167,23 +216,18 @@ BASE_ID="${BASE_ID:?set to a throwaway sandbox base, never a production base}"
|
|||||||
# Read plugin ID from manifest
|
# Read plugin ID from manifest
|
||||||
PLUGIN_ID=$(python3 -c "import json; print(json.load(open('$DIR/plugin.json'))['id'])")
|
PLUGIN_ID=$(python3 -c "import json; print(json.load(open('$DIR/plugin.json'))['id'])")
|
||||||
|
|
||||||
# Get session
|
# Upload: teable-plugin deploy handles entry + chunks + maps + css.
|
||||||
# PAT preferred (upload-bundle accepts Bearer auth):
|
# Raw-curl equivalent for a single-file bundle:
|
||||||
AUTH=(-H "Authorization: Bearer ${PAT:?set PAT env}")
|
# curl -s -H "Authorization: Bearer $TEABLE_PAT" \
|
||||||
# ...or session:
|
# -F "js=@$DIR/dist/main.js" -F "css=@$DIR/dist/styles.css" \
|
||||||
SESSION=$(grep auth_session /tmp/cookie.txt 2>/dev/null | awk '{print $NF}')
|
# -F "manifest=@$DIR/plugin.json" \
|
||||||
AUTH=(-b "auth_session=$SESSION")
|
# "$UPLOAD_URL/api/plugin/$PLUGIN_ID/upload-bundle"
|
||||||
|
TEABLE_ENDPOINT="$UPLOAD_URL" TEABLE_PAT="${TEABLE_PAT:?set PAT env}" \
|
||||||
# Upload (URL includes pluginId from manifest)
|
node /root/deployments/inkreach-teable/packages/plugin-cli/dist/index.js deploy -d "$DIR"
|
||||||
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"
|
|
||||||
echo "Uploaded: $PLUGIN_ID"
|
echo "Uploaded: $PLUGIN_ID"
|
||||||
|
|
||||||
# Enable
|
# 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" \
|
"$UPLOAD_URL/api/base/$BASE_ID/plugin-config/$PLUGIN_ID" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"enabled":true,"tableIds":[]}' > /dev/null
|
-d '{"enabled":true,"tableIds":[]}' > /dev/null
|
||||||
|
|||||||
@@ -1,18 +1,37 @@
|
|||||||
# Plugin development workflow
|
# Plugin development workflow
|
||||||
|
|
||||||
The standard recipe for creating, building, and deploying a dynamic React
|
The standard recipe for creating, building, and deploying a dynamic React
|
||||||
component plugin. Each plugin is a standalone webpack project that outputs
|
component plugin. Each plugin is a standalone webpack (or vite) project that
|
||||||
an IIFE bundle.
|
outputs an IIFE bundle. Since 2026-10 the pipeline is multi-asset: lazy
|
||||||
|
chunks, source maps, and multiple CSS files upload alongside the entry.
|
||||||
|
|
||||||
## Output contract
|
## Output contract
|
||||||
|
|
||||||
Every plugin task ends with:
|
Every plugin task ends with:
|
||||||
1. A built IIFE bundle (`dist/main.js` + optional `dist/styles.css`)
|
1. A built IIFE bundle (`dist/main.js` + optional chunks/`*.chunk.js` +
|
||||||
|
`*.js.map` source maps + optional `styles.css`)
|
||||||
2. A `plugin.json` manifest
|
2. A `plugin.json` manifest
|
||||||
3. Uploaded to the host via `POST /api/plugin/:id/upload-bundle`
|
3. Uploaded to the host via `POST /api/plugin/:id/upload-bundle`
|
||||||
|
(or `teable-plugin deploy` which handles all assets)
|
||||||
4. Enabled via `PUT /api/base/:baseId/plugin-config/:pluginId`
|
4. Enabled via `PUT /api/base/:baseId/plugin-config/:pluginId`
|
||||||
5. Installed to the target position (the install API varies by position)
|
5. Installed to the target position (the install API varies by position)
|
||||||
|
|
||||||
|
## Dev loop (hot reload — preferred while iterating)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd plugins/my-plugin # or wherever the project lives
|
||||||
|
TEABLE_ENDPOINT=http://localhost:3003 TEABLE_PAT=<pat> pnpm dev
|
||||||
|
# or: node ../../packages/plugin-cli/dist/index.js dev -d .
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs `webpack --watch` (production mode — dev-mode output carries
|
||||||
|
`/******/` banners that fail the IIFE validator), auto-uploads on every
|
||||||
|
change with a `devMode: true, buildId: <ts>` manifest, and the open host
|
||||||
|
page remounts the plugin within ~4s WITHOUT a page refresh. Production
|
||||||
|
deploys (no devMode in manifest) never poll. Verified 2026-10-13 on
|
||||||
|
plugin-showcase: edit → ~5s live update, window markers survive (SPA-level
|
||||||
|
remount, not a reload).
|
||||||
|
|
||||||
## Step 1 — Understand the request
|
## Step 1 — Understand the request
|
||||||
|
|
||||||
Clarify what the user wants, then pick the position using `overview.md`.
|
Clarify what the user wants, then pick the position using `overview.md`.
|
||||||
@@ -26,23 +45,33 @@ to npm). Run it directly:
|
|||||||
```bash
|
```bash
|
||||||
cd /tmp
|
cd /tmp
|
||||||
node /root/deployments/inkreach-teable/packages/plugin-cli/dist/index.js create my-plugin
|
node /root/deployments/inkreach-teable/packages/plugin-cli/dist/index.js create my-plugin
|
||||||
|
# or the vite template:
|
||||||
|
node /root/deployments/inkreach-teable/packages/plugin-cli/dist/index.js create my-plugin --template vite
|
||||||
cd my-plugin
|
cd my-plugin
|
||||||
pnpm install
|
pnpm install
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The scaffold adapts automatically: inside the monorepo it wires
|
||||||
|
`@teable/plugin-sdk: workspace:*` + source aliases; standalone projects get a
|
||||||
|
loose-typed local host module (the npm package is not published). Vite
|
||||||
|
scaffolds also generate a `pnpm-workspace.yaml` with `allowBuilds: esbuild`
|
||||||
|
(pnpm 11 fails `pnpm install` without it).
|
||||||
|
|
||||||
This generates:
|
This generates:
|
||||||
```
|
```
|
||||||
my-plugin/
|
my-plugin/
|
||||||
├── src/
|
├── src/
|
||||||
│ └── index.tsx # entry: calls definePlugin()
|
│ ├── host.ts # typed host accessor
|
||||||
|
│ └── index.tsx # entry: definePlugin via host.PluginSDK
|
||||||
├── plugin.json # manifest
|
├── plugin.json # manifest
|
||||||
├── webpack.config.js # pre-configured externals
|
├── webpack.config.js # or vite.config.ts
|
||||||
├── tsconfig.json
|
├── tsconfig.json
|
||||||
└── package.json
|
└── package.json # dev/build/deploy scripts
|
||||||
```
|
```
|
||||||
|
|
||||||
If the CLI doesn't work for some reason, manually create the structure by
|
Reference implementations in the repo: `plugins/plugin-showcase/` (chunks +
|
||||||
copying `plugins/batch-edit-pro/` as a template.
|
sourcemap + Tailwind + HashRouter route app — the v2 showcase) and
|
||||||
|
`plugins/batch-edit-pro/` (classic single-file contextMenu plugin).
|
||||||
|
|
||||||
## Step 3 — Write the plugin code
|
## Step 3 — Write the plugin code
|
||||||
|
|
||||||
@@ -117,59 +146,114 @@ definePlugin({
|
|||||||
|
|
||||||
## Step 4 — Build
|
## Step 4 — Build
|
||||||
|
|
||||||
```bash
|
|
||||||
NODE_OPTIONS='--max-old-space-size=2048' npx webpack --config webpack.config.js
|
|
||||||
```
|
|
||||||
|
|
||||||
Or if the plugin has a build script:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm build
|
pnpm build
|
||||||
# or: npm run build
|
# = tsc --noEmit && webpack --mode production (scaffold script)
|
||||||
```
|
```
|
||||||
|
|
||||||
Outputs: `dist/main.js` (IIFE) + `dist/styles.css` (if styles exist).
|
**Rebuilding without the scaffold — use this exact config** (agents
|
||||||
|
hand-writing webpack configs keep hitting `Error: Unsupported library type
|
||||||
|
iife` — there is NO `output.library` in a working plugin config; a default
|
||||||
|
single-entry production build is already IIFE, which is what the upload
|
||||||
|
validator requires):
|
||||||
|
|
||||||
Verify the output:
|
```js
|
||||||
```bash
|
// webpack.config.js — known-good v2 (from plugins/plugin-showcase/)
|
||||||
ls -la dist/main.js dist/styles.css plugin.json
|
const path = require('path');
|
||||||
|
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||||
|
const TerserPlugin = require('terser-webpack-plugin');
|
||||||
|
|
||||||
|
const pluginId = require('./plugin.json').id;
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
mode: 'production',
|
||||||
|
entry: './src/index.tsx',
|
||||||
|
output: {
|
||||||
|
path: path.resolve(__dirname, 'dist'),
|
||||||
|
filename: 'main.js',
|
||||||
|
// FLAT chunk names: multipart transport strips directory parts, so
|
||||||
|
// nested chunk paths would 404 at runtime.
|
||||||
|
chunkFilename: '[name]-[contenthash].chunk.js',
|
||||||
|
// required so React.lazy/import() chunks resolve from the host runtime
|
||||||
|
publicPath: `/api/plugin/${pluginId}/runtime/`,
|
||||||
|
clean: true,
|
||||||
|
// terser's minified "(()=>{" fails the validator's "(() =>" check
|
||||||
|
environment: { arrowFunction: false },
|
||||||
|
},
|
||||||
|
devtool: 'source-map', // maps upload + serve as application/json
|
||||||
|
// NO output.library — never add library.type: 'iife' (webpack rejects it)
|
||||||
|
externals: {
|
||||||
|
'react': 'window.__TEABLE_HOST__.React',
|
||||||
|
'react-dom': 'window.__TEABLE_HOST__.ReactDOM',
|
||||||
|
},
|
||||||
|
resolve: { extensions: ['.tsx', '.ts', '.js', '.jsx'] },
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{ test: /\.tsx?$/, loader: 'ts-loader', options: { transpileOnly: true }, exclude: /node_modules/ },
|
||||||
|
{ test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
optimization: {
|
||||||
|
minimizer: [
|
||||||
|
// license-comment extraction prepends a "/*!..." banner that breaks
|
||||||
|
// the IIFE-prefix check — keep comments inline
|
||||||
|
new TerserPlugin({ extractComments: false }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
plugins: [new MiniCssExtractPlugin({ filename: 'styles.css' })],
|
||||||
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`react/jsx-runtime` is intentionally NOT external: the jsx automatic
|
||||||
|
transform bundles it and that is safe (element factories only — hooks still
|
||||||
|
come from the host singleton).
|
||||||
|
|
||||||
|
Vite template notes: `build.lib formats: ['iife']` + rollup externals with
|
||||||
|
`globals: { react: 'window.__TEABLE_HOST__.React', ... }`; output starts
|
||||||
|
`(function(...)` or `var <name>=(function...` — both pass the validator.
|
||||||
|
IIFE cannot code-split (dynamic imports inline) — use the webpack template
|
||||||
|
if you need lazy chunks.
|
||||||
|
|
||||||
|
If you build outside the monorepo, drop the `resolve.alias` block (it exists
|
||||||
|
only so TS can type-check `import type` from `@teable/*` sources) and use
|
||||||
|
`import type` + `as unknown as` casts instead of runtime imports.
|
||||||
|
|
||||||
|
Outputs: `dist/main.js` (IIFE) + optional `dist/*.chunk.js` (lazy chunks) +
|
||||||
|
`dist/*.js.map` + `dist/styles.css`.
|
||||||
|
|
||||||
## Step 5 — Upload the bundle
|
## Step 5 — Upload the bundle
|
||||||
|
|
||||||
The upload URL includes the plugin's `id` from `plugin.json`. Extract it first,
|
Preferred (handles entry + chunks + maps + css automatically):
|
||||||
then upload.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
SESSION=$(grep auth_session /tmp/cookie_hr.txt 2>/dev/null | awk '{print $NF}')
|
TEABLE_ENDPOINT=http://localhost:3003 TEABLE_PAT=<pat> \
|
||||||
if [ -z "$SESSION" ]; then
|
node ../../packages/plugin-cli/dist/index.js deploy -d .
|
||||||
SESSION=$(curl -s -c - http://localhost:3003/api/auth/signin \
|
# or from the plugin dir: pnpm deploy
|
||||||
-X POST -H 'Content-Type: application/json' \
|
```
|
||||||
-d '{"email":"hr_admin@xingchen.tech","password":"Xingchen@2026"}' \
|
|
||||||
| grep auth_session | awk '{print $NF}')
|
|
||||||
|
|
||||||
> Account note (2026-08): `hr_admin@xingchen.tech` no longer exists on the
|
Raw curl equivalent (single-file legacy form still works):
|
||||||
> deployed instance. Create a throwaway user via `POST /api/auth/signup`
|
|
||||||
> and add a `plugin_developer` row for it before uploading (see SKILL.md).
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
```bash
|
||||||
PLUGIN_ID=$(python3 -c "import json; print(json.load(open('plugin.json'))['id'])")
|
PLUGIN_ID=$(python3 -c "import json; print(json.load(open('plugin.json'))['id'])")
|
||||||
|
curl -s -H "Authorization: Bearer $TEABLE_PAT" \
|
||||||
curl -s -b "auth_session=$SESSION" \
|
|
||||||
-F "js=@dist/main.js" \
|
-F "js=@dist/main.js" \
|
||||||
-F "css=@dist/styles.css" \
|
-F "css=@dist/styles.css" \
|
||||||
-F "manifest=@plugin.json" \
|
-F "manifest=@plugin.json" \
|
||||||
"http://localhost:3003/api/plugin/$PLUGIN_ID/upload-bundle"
|
"http://localhost:3003/api/plugin/$PLUGIN_ID/upload-bundle"
|
||||||
|
|
||||||
echo "PLUGIN_ID=$PLUGIN_ID"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- The plugin `id` in the URL must match the `id` in `plugin.json`. On first
|
Multipart notes (v2, 2026-10):
|
||||||
upload, the plugin is created; on subsequent uploads, the existing plugin is
|
- `js` field accepts up to 20 files (entry first or matched by manifest
|
||||||
updated.
|
entry filename), `css` up to 5, `map` up to 20.
|
||||||
- Auth supports both session cookies (`auth_session`) and Personal Access Tokens
|
- The plugin row must already exist (`POST /api/plugin {"id": "<kebab-id>"}`
|
||||||
(`Authorization: Bearer <PAT>`).
|
— custom ids require the plugin_developer whitelist; the endpoint is
|
||||||
- Returns 201 on success.
|
`@TokenAccess()` so a PAT works).
|
||||||
|
- Limits: 5MB per JS/map file, 1MB per CSS, 15MB total. The bundleDir is
|
||||||
|
WIPED before writing — stale chunks don't linger, but hand-patched files
|
||||||
|
on disk are discarded on the next upload.
|
||||||
|
- Auth supports session cookies (`auth_session`) and PATs
|
||||||
|
(`Authorization: Bearer`). Runtime GETs (`/api/plugin/:id/runtime/*`) are
|
||||||
|
session-only (PAT gets 403 by design).
|
||||||
|
|
||||||
## Step 6 — Enable (REQUIRED before install)
|
## Step 6 — Enable (REQUIRED before install)
|
||||||
|
|
||||||
@@ -203,6 +287,11 @@ Open the relevant page and confirm the plugin renders and works:
|
|||||||
- Panel: bottom-right Plugins button in a table view
|
- Panel: bottom-right Plugins button in a table view
|
||||||
- View: view-type dropdown
|
- View: view-type dropdown
|
||||||
- Dashboard: space dashboard page
|
- Dashboard: space dashboard page
|
||||||
|
- Route (full-page app): `/base/<baseId>/p/<pluginId>` — install at position
|
||||||
|
`route`, the page renders the plugin full-screen inside BaseLayout (bring
|
||||||
|
your own router; HashRouter recommended so Next.js routing is untouched).
|
||||||
|
Verified flow: enable via plugin-config → install via
|
||||||
|
`POST /api/base/:baseId/plugin-installs {"position":"route"}` → open the URL.
|
||||||
|
|
||||||
Use `browser-act` to verify or ask the user to check.
|
Use `browser-act` to verify or ask the user to check.
|
||||||
|
|
||||||
@@ -210,14 +299,18 @@ Use `browser-act` to verify or ask the user to check.
|
|||||||
|
|
||||||
Before claiming done:
|
Before claiming done:
|
||||||
|
|
||||||
- [ ] `dist/main.js` built and is a valid IIFE bundle
|
- [ ] `dist/main.js` built and is a valid IIFE bundle (`!function` / `(function` /
|
||||||
|
`(() =>` / `var x=(function` prefix — run `head -c 20 dist/main.js`)
|
||||||
|
- [ ] Chunks (if any) are FLAT names (`[name]-[contenthash].chunk.js`)
|
||||||
- [ ] `plugin.json` at project root with correct `id`, `entry`, `positions`
|
- [ ] `plugin.json` at project root with correct `id`, `entry`, `positions`
|
||||||
- [ ] No hardcoded URLs/table IDs/tokens — all via host SDK
|
- [ ] No hardcoded URLs/table IDs/tokens — all via host SDK
|
||||||
- [ ] Loading, error, and empty states handled in the UI
|
- [ ] Loading, error, and empty states handled in the UI
|
||||||
- [ ] `definePlugin()` called with `onLoad(ctx)` that registers at least one component
|
- [ ] `definePlugin()` called with `onLoad(ctx)` that registers at least one component
|
||||||
- [ ] Upload returns 201, pluginId captured
|
- [ ] Upload returns 2xx (assets count > 1 when chunks/maps exist)
|
||||||
- [ ] Enable (plugin-config) called before install
|
- [ ] Enable (plugin-config) called before install
|
||||||
- [ ] Install returns 2xx
|
- [ ] Install returns 2xx
|
||||||
- [ ] Plugin renders and works
|
- [ ] Plugin renders and works
|
||||||
|
- [ ] Dev-loop deploys redone as a production upload (no `devMode` in the
|
||||||
|
final manifest) before handover
|
||||||
|
|
||||||
If any box is unchecked, fix it.
|
If any box is unchecked, fix it.
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Native form embedding — quick answer
|
||||||
|
|
||||||
|
**问题:能不能在插件里做自定义布局的表单(多步表单/侧栏表单),每一步用系统
|
||||||
|
原生字段编辑器,不自己重写输入组件?**
|
||||||
|
|
||||||
|
**能。宿主暴露了完整的原生表单注入能力,共两档。咨询类问题到这里就有答案了,
|
||||||
|
不需要先去搭工程验证。**
|
||||||
|
|
||||||
|
| 需求 | 用哪档 | 核心 API(都在 `window.__TEABLE_HOST__`) |
|
||||||
|
|---|---|---|
|
||||||
|
| 整表单嵌入面板/视图,能裁剪字段、插自定义内容 | **Tier-1 整表注入** | `host.NativeForm` |
|
||||||
|
| 自己控制每行渲染、逐步表单、校验与暂存 | **Tier-2 逐字段套件** | `host.NativeFormScope` + `host.NativeFormField` + `host.useNativeForm()` |
|
||||||
|
|
||||||
|
## Tier-1:`<NativeForm>` 整表注入(最常用)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { NativeForm } = window.__TEABLE_HOST__;
|
||||||
|
|
||||||
|
function MyFormPanel() {
|
||||||
|
return (
|
||||||
|
<NativeForm
|
||||||
|
formId="viwIssueForm" // 一个 form 视图的 id
|
||||||
|
visibleFieldIds={['fldTitle', 'fldDesc']} // 裁剪+重排(仅会话级)
|
||||||
|
hiddenFieldIds={['fld_internalNote']} // 或只隐藏
|
||||||
|
slots={[ // 在任意位置插自定义内容
|
||||||
|
{ position: 'top', render: ({ values }) => <h3>提单</h3> },
|
||||||
|
]}
|
||||||
|
interceptSubmit={(fields) => fields} // 提交前改写载荷(原生提交与自动化照常)
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
要点:原生提交、校验、`formSubmitted` 自动化全部照常;虚拟值放
|
||||||
|
`__virtual.<key>`,提交时自动剥离。
|
||||||
|
|
||||||
|
## Tier-2:多步表单(逐步切换字段、原生编辑器)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { NativeFormScope, NativeFormField, useNativeForm } = window.__TEABLE_HOST__;
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
['fldTitle', 'fldCustomer'],
|
||||||
|
['fldAmount', 'fldUrgency'],
|
||||||
|
['fldNotes'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function Stepper({ formId }: { formId: string }) {
|
||||||
|
return (
|
||||||
|
<NativeFormScope formId={formId}> {/* 状态/L localStorage 暂存由 Scope 接管 */}
|
||||||
|
<StepBody formId={formId} />
|
||||||
|
</NativeFormScope>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepBody({ formId }: { formId: string }) {
|
||||||
|
const { values, setValue, errors, fields, step, setStep } = useNativeForm({ formId, steps: STEPS });
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{STEPS[step].map((fid) => (
|
||||||
|
<NativeFormField key={fid} fieldId={fid} /> {/* 100% 原生字段编辑器 */}
|
||||||
|
))}
|
||||||
|
<button onClick={() => setStep(step - 1)} disabled={step === 0}>上一步</button>
|
||||||
|
<button onClick={() => setStep(step + 1)} disabled={step === STEPS.length - 1}>下一步</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 决策与边界
|
||||||
|
|
||||||
|
- 校验/错误提示:走原生(`errors` 由 hook 暴露);自定义校验放 `interceptSubmit`。
|
||||||
|
- `submitOverride` 会**绕过**原生提交 → `formSubmitted` 自动化不会触发(要触发就别用它)。
|
||||||
|
- computed/lookup 字段永远不可编辑(原生行为,注入也一样)。
|
||||||
|
- 详细 props 表与虚拟字段模式见 [sdk-api.md](sdk-api.md) 的 `NativeForm` / `NativeFormScope` 章节。
|
||||||
@@ -49,11 +49,17 @@ gated by a developer whitelist and bundles are validated for security.
|
|||||||
→ check minAppVersion compatibility
|
→ check minAppVersion compatibility
|
||||||
→ <link> inject CSS (if manifest.style exists)
|
→ <link> inject CSS (if manifest.style exists)
|
||||||
→ <script src="/api/plugin/:id/runtime/main.js"> (IIFE executes)
|
→ <script src="/api/plugin/:id/runtime/main.js"> (IIFE executes)
|
||||||
|
→ if the bundle code-splits, lazy chunks load from the same runtime
|
||||||
|
endpoint (output.publicPath = /api/plugin/:id/runtime/), served as
|
||||||
|
plain .js assets
|
||||||
→ script calls definePlugin({ id, name, onLoad, ... })
|
→ script calls definePlugin({ id, name, onLoad, ... })
|
||||||
→ window.__TEABLE_PLUGINS__[pluginId] = config
|
→ window.__TEABLE_PLUGINS__[pluginId] = config
|
||||||
→ PluginLoader reads registration, creates PluginContext, calls onLoad(ctx)
|
→ PluginLoader reads registration, creates PluginContext, calls onLoad(ctx)
|
||||||
→ ctx.addContextMenuAction(...) etc. register React components to componentRegistry
|
→ ctx.addContextMenuAction(...) etc. register React components to componentRegistry
|
||||||
→ PluginSlot renders registered components at the right mount point
|
→ PluginSlot renders registered components at the right mount point
|
||||||
|
→ if manifest.devMode is true (dev loop only), the loader polls the
|
||||||
|
manifest every 4s and remounts the plugin when buildId changes —
|
||||||
|
no page refresh (production manifests never set devMode)
|
||||||
|
|
||||||
3. Plugin unloads
|
3. Plugin unloads
|
||||||
→ onUnload(ctx) called
|
→ onUnload(ctx) called
|
||||||
@@ -117,32 +123,41 @@ Location: `apps/nextjs-app/src/features/app/components/plugin/host/event-bus.ts`
|
|||||||
|
|
||||||
```
|
```
|
||||||
dist/
|
dist/
|
||||||
├── main.js # IIFE bundle (webpack output)
|
├── main.js # IIFE entry bundle (webpack/vite output)
|
||||||
└── styles.css # optional stylesheet
|
├── <name>-<hash>.chunk.js # optional lazy chunks (code splitting) — FLAT names
|
||||||
|
├── *.js.map # optional source maps (served as application/json)
|
||||||
|
└── styles.css # optional stylesheet (Tailwind/SCSS output OK)
|
||||||
plugin.json # manifest
|
plugin.json # manifest
|
||||||
```
|
```
|
||||||
|
|
||||||
## Shared dependency bridging
|
## Shared dependency bridging
|
||||||
|
|
||||||
Plugin webpack config marks these as externals, mapping to host globals:
|
Plugin webpack config marks ONLY the React singleton as external, mapping to
|
||||||
|
a host global:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
externals: {
|
externals: {
|
||||||
'react': 'window.__TEABLE_HOST__.React',
|
'react': 'window.__TEABLE_HOST__.React',
|
||||||
'react-dom': 'window.__TEABLE_HOST__.ReactDOM',
|
'react-dom': 'window.__TEABLE_HOST__.ReactDOM',
|
||||||
'@teable/sdk': 'window.__TEABLE_HOST__.SDK',
|
|
||||||
'@teable/ui-lib': 'window.__TEABLE_HOST__.UILib',
|
|
||||||
'@teable/openapi': 'window.__TEABLE_HOST__.OpenAPI',
|
|
||||||
'@teable/plugin-sdk': 'window.__TEABLE_HOST__.PluginSDK',
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
This means the plugin does NOT bundle React or host packages — it uses the
|
Everything else on the host (`SDK`, `UILib`, `OpenAPI`, `PluginSDK`) is read
|
||||||
host's singleton instances. The bundle only contains the plugin's own code.
|
from `window.__TEABLE_HOST__` at runtime — there is NO working externals
|
||||||
|
mapping for `@teable/*` packages (a value import via the old
|
||||||
|
window-global-externals style compiled to `undefined`; via monorepo source
|
||||||
|
aliases it would inline the whole package source — see troubleshooting.md
|
||||||
|
Pitfall 1). `react/jsx-runtime` is intentionally NOT external: the jsx
|
||||||
|
automatic transform bundles it, which is safe — it only creates element
|
||||||
|
objects, while hooks/context/state still resolve through the externalized
|
||||||
|
`react` → host singleton.
|
||||||
|
|
||||||
|
Other npm dependencies (react-router, charts, lodash…) are bundled into the
|
||||||
|
plugin as normal.
|
||||||
|
|
||||||
## Two ways to access host APIs
|
## Two ways to access host APIs
|
||||||
|
|
||||||
### 1. Through `window.__TEABLE_HOST__` (recommended for IIFE)
|
### 1. Through `window.__TEABLE_HOST__` (the only runtime path)
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const host = window.__TEABLE_HOST__;
|
const host = window.__TEABLE_HOST__;
|
||||||
@@ -153,17 +168,21 @@ const { getRecords } = host.OpenAPI;
|
|||||||
const { definePlugin, useSelection } = host.PluginSDK;
|
const { definePlugin, useSelection } = host.PluginSDK;
|
||||||
```
|
```
|
||||||
|
|
||||||
This is what `plugins/batch-edit-pro/` uses — the plugin bundle has no
|
This is what `plugins/plugin-showcase/` and `plugins/batch-edit-pro/` use —
|
||||||
`@teable/*` imports, everything resolves through the host global.
|
the plugin bundle has no `@teable/*` runtime imports, everything resolves
|
||||||
|
through the host global. `import { useState } from 'react'` also works (the
|
||||||
|
externals mapping above redirects it to the host singleton).
|
||||||
|
|
||||||
### 2. Through `@teable/plugin-sdk` (also works because webpack externals)
|
### 2. `import type` from `@teable/*` (type-checking only)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { definePlugin } from '@teable/plugin-sdk';
|
import type { ITeableHost } from '@teable/plugin-sdk';
|
||||||
|
export const host = window.__TEABLE_HOST__ as unknown as ITeableHost;
|
||||||
```
|
```
|
||||||
|
|
||||||
At build time, webpack resolves this to `window.__TEABLE_HOST__.PluginSDK.definePlugin`.
|
`import type` is erased at compile time — it never reaches the bundle. This
|
||||||
Both styles work — the externals config handles the mapping.
|
is how scaffolded projects get full type safety inside the monorepo
|
||||||
|
(standalone scaffolds ship a loose-typed local host module instead).
|
||||||
|
|
||||||
## Positions and registration
|
## Positions and registration
|
||||||
|
|
||||||
@@ -173,4 +192,4 @@ Both styles work — the externals config handles the mapping.
|
|||||||
| Panel | Right-side drawer of a table view | `ctx.addPanel(id, component)` |
|
| Panel | Right-side drawer of a table view | `ctx.addPanel(id, component)` |
|
||||||
| View | View-type dropdown (alongside grid/form/gallery) | `ctx.addView(id, component)` |
|
| View | View-type dropdown (alongside grid/form/gallery) | `ctx.addView(id, component)` |
|
||||||
| Dashboard | Grid card on space dashboard | `ctx.addDashboardWidget(id, component)` |
|
| Dashboard | Grid card on space dashboard | `ctx.addDashboardWidget(id, component)` |
|
||||||
| Route | Full-page route (e.g., `/plugin/:pluginId/my-page`) | `ctx.addRoute(path, component)` |
|
| Route | Full-page app at `/base/:baseId/p/:pluginId` (whole viewport inside BaseLayout; own router OK, HashRouter recommended) | `ctx.addRoute(id, component)` |
|
||||||
|
|||||||
@@ -59,9 +59,17 @@ ctx.addContextMenuAction(id: string, component: React.FC): Disposable;
|
|||||||
ctx.addPanel(id: string, component: React.FC): Disposable;
|
ctx.addPanel(id: string, component: React.FC): Disposable;
|
||||||
ctx.addDashboardWidget(id: string, component: React.FC): Disposable;
|
ctx.addDashboardWidget(id: string, component: React.FC): Disposable;
|
||||||
ctx.addView(id: string, component: React.FC): Disposable;
|
ctx.addView(id: string, component: React.FC): Disposable;
|
||||||
ctx.addRoute(path: string, component: React.FC): Disposable;
|
ctx.addRoute(id: string, component: React.FC): Disposable;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Route position** (full-page apps, since 2026-10): the component renders at
|
||||||
|
`/base/<baseId>/p/<pluginId>` — the whole main-area viewport inside the base
|
||||||
|
layout, so SDK hooks like `host.SDK.useBaseId()` work. Install the plugin at
|
||||||
|
position `"route"` (`POST /api/base/:baseId/plugin-installs`) and include
|
||||||
|
`"route"` in `manifest.positions`. The plugin brings its own router — use
|
||||||
|
HashRouter (e.g. `react-router-dom` bundled as a normal npm dep) so Next.js
|
||||||
|
routing is untouched. Working example: `plugins/plugin-showcase/src/App.tsx`.
|
||||||
|
|
||||||
### Events
|
### Events
|
||||||
|
|
||||||
#### `ctx.on(event, handler): Disposable`
|
#### `ctx.on(event, handler): Disposable`
|
||||||
|
|||||||
@@ -104,3 +104,46 @@ If you're setting up the webpack config manually, make sure:
|
|||||||
- The plugin does NOT need `import React from 'react'` at the top when using
|
- The plugin does NOT need `import React from 'react'` at the top when using
|
||||||
`react-jsx` transform — JSX is auto-transpiled. But you still need
|
`react-jsx` transform — JSX is auto-transpiled. But you still need
|
||||||
`const React = host.React` for any explicit `React.createElement` calls.
|
`const React = host.React` for any explicit `React.createElement` calls.
|
||||||
|
|
||||||
|
## Pitfall 8: Chunk 404 at runtime — multipart strips directory names (2026-10)
|
||||||
|
|
||||||
|
`webpack output.chunkFilename: 'static/js/[name].chunk.js'` builds fine, the
|
||||||
|
upload succeeds, but the browser 404s the chunk: multer/busboy flattens
|
||||||
|
multipart filenames, so the server stored `469.chunk.js` while the bundle
|
||||||
|
requests `static/js/469.chunk.js`.
|
||||||
|
|
||||||
|
**Fix**: keep chunk names FLAT — `chunkFilename: '[name]-[contenthash].chunk.js'`
|
||||||
|
(contenthash prevents collisions). Public path still works:
|
||||||
|
`output.publicPath: '/api/plugin/<id>/runtime/'`.
|
||||||
|
|
||||||
|
## Pitfall 9: `Bundle must be IIFE format` after a successful build
|
||||||
|
|
||||||
|
Three producer-side causes, all seen in the wild:
|
||||||
|
1. **Terser license banner** — bundling any npm package with license comments
|
||||||
|
makes webpack emit `/*! For license information... */` BEFORE `!function`.
|
||||||
|
Fix: `optimization.minimizer: [new TerserPlugin({ extractComments: false })]`.
|
||||||
|
2. **Development-mode webpack** output starts with `/******/` banner comments.
|
||||||
|
The dev watch loop therefore builds with `--mode production`.
|
||||||
|
3. **Vite/esbuild IIFE** output starts `var <name>=(function...` — the
|
||||||
|
validator accepts it (since 2026-10); no post-processing needed.
|
||||||
|
|
||||||
|
## Pitfall 10: vite scaffold `pnpm install` fails with ERR_PNPM_IGNORED_BUILDS
|
||||||
|
|
||||||
|
pnpm ≥ 10/11 blocks esbuild's postinstall and (on patched hosts) treats it as
|
||||||
|
fatal. Fix (scaffold generates it): `pnpm-workspace.yaml` with
|
||||||
|
`allowBuilds:\n esbuild: true`. esbuild also works without its script (binary
|
||||||
|
comes from optionalDependencies) — only the pnpm gate needs the allow entry.
|
||||||
|
|
||||||
|
## Pitfall 11: PAT gets 403 fetching `/api/plugin/:id/runtime/*`
|
||||||
|
|
||||||
|
Access tokens only pass routes with `@TokenAccess()` or `@Permissions` — the
|
||||||
|
runtime asset serving has neither. This is by design (the browser loads with a
|
||||||
|
session). For curl verification use a signed `auth_session` cookie; for uploads
|
||||||
|
use the PAT (`upload-bundle` and `POST /api/plugin` are token-accessible).
|
||||||
|
|
||||||
|
## Pitfall 12: "react/jsx-runtime would duplicate React" worry
|
||||||
|
|
||||||
|
It does not. The jsx automatic transform imports `react/jsx-runtime`, which is
|
||||||
|
bundled into the plugin on purpose: it only creates plain element objects.
|
||||||
|
Hooks/context/state go through the externalized `react` → host singleton. This
|
||||||
|
is exactly how the deployed ticket-console/batch-edit-pro bundles behave.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"organization": "inkreach",
|
"organization": "inkreach",
|
||||||
"authors": ["inkreach"],
|
"authors": ["inkreach"],
|
||||||
"tags": ["plugin", "teable", "zip", "bridge", "context-menu", "dashboard", "panel", "view", "iframe"],
|
"tags": ["plugin", "teable", "bundle", "iife", "webpack", "vite", "context-menu", "dashboard", "panel", "view", "route", "hot-reload", "source-map"],
|
||||||
"abstract": "Develop ZIP plugins for inkreach-teable: 4 mount positions, penpal bridge API, AI-friendly templates and examples."
|
"abstract": "Develop dynamic React component plugins for inkreach-teable: IIFE bundles (webpack or vite) loaded at runtime via window.__TEABLE_HOST__ with 5 mount positions including full-page route apps, multi-asset uploads (lazy chunks + source maps), a watch-and-hot-reload dev loop, and the teable-plugin CLI scaffold."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,13 +9,20 @@ description: >-
|
|||||||
Operate a deployed inkreach-teable instance via the `inkreach-teable` CLI.
|
Operate a deployed inkreach-teable instance via the `inkreach-teable` CLI.
|
||||||
Respond in the user's language. Keep answers concise and action-oriented.
|
Respond in the user's language. Keep answers concise and action-oriented.
|
||||||
|
|
||||||
|
## 0. 执行守则
|
||||||
|
|
||||||
|
- 多步任务先规划再动手;**完成全部步骤后才给最终答复**,不要中途停止或只给命令不给结果。
|
||||||
|
- 完成后如实汇报:做了什么、关键命令、结果;**做不到就明说做不到及原因,绝不谎报成功**。
|
||||||
|
- 长任务(批量记录、多工作流)分批执行,每批确认后再继续。
|
||||||
|
- **改错了怎么撤销**:workflow 的 snapshot 仅供审计,**没有恢复 API**——用 `workflow deactivate` 止损,再用 `trigger set` / `action update` 改回(节点更新是全图替换,直接改回正确配置即可);整条不要了用 `workflow delete`(软删)。
|
||||||
|
|
||||||
> **Critical**: the automation engine is **self-built and differs from official
|
> **Critical**: the automation engine is **self-built and differs from official
|
||||||
> teable** — the official `teable` CLI corrupts or 404s on automations here.
|
> teable** — the official `teable` CLI corrupts or 404s on automations here.
|
||||||
> Exact key-name differences are in [Key Constraints](#3-key-constraints).
|
> Exact key-name differences are in [Key Constraints](#3-key-constraints).
|
||||||
|
|
||||||
## 1. Prerequisites
|
## 1. Prerequisites
|
||||||
|
|
||||||
- Install the CLI if missing: `npm i -g @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).
|
- **Configure once**: `inkreach-teable auth login --endpoint https://table.inkreach.cc --token <PAT> [--base-id bseXXX]`; verify with `auth status`. PAT comes from `auth token create --name x` (full token shown **once**) or the web UI (Settings → Personal Access Tokens).
|
||||||
- `--json` on any command returns raw JSON (for agent parsing). Fallback for unknown commands: `inkreach-teable <cmd> --help`.
|
- `--json` on any command returns raw JSON (for agent parsing). Fallback for unknown commands: `inkreach-teable <cmd> --help`.
|
||||||
- **Read on demand**: consult the guide linked for your module when doing non-trivial work — no need to read everything up front.
|
- **Read on demand**: consult the guide linked for your module when doing non-trivial work — no need to read everything up front.
|
||||||
@@ -25,14 +32,13 @@ Respond in the user's language. Keep answers concise and action-oriented.
|
|||||||
| Module | What it solves | Entry commands | Detail doc |
|
| Module | What it solves | Entry commands | Detail doc |
|
||||||
|--------|---------------|----------------|------------|
|
|--------|---------------|----------------|------------|
|
||||||
| Tables & Fields | Create/modify structure; **inspect select-choice constraints** | `table create`, `field create`, `field list --with-options`, `field get` | [tables-fields.md](guides/tables-fields.md), [field-types.md](api-reference/field-types.md) |
|
| Tables & Fields | Create/modify structure; **inspect select-choice constraints** | `table create`, `field create`, `field list --with-options`, `field get` | [tables-fields.md](guides/tables-fields.md), [field-types.md](api-reference/field-types.md) |
|
||||||
| Records | Read/write row data | `record get`, `record create/update/delete` | [tables-fields.md § Records](guides/tables-fields.md) |
|
| Records | Read/write row data | `record get` (alias `record list`), `record create/update/delete` | [tables-fields.md § Records](guides/tables-fields.md) |
|
||||||
| Views | Filtered/sorted perspectives | `view create/update` | [tables-fields.md § Views](guides/tables-fields.md) |
|
| Views | Filtered/sorted perspectives | `view create/update` | [tables-fields.md § Views](guides/tables-fields.md) |
|
||||||
| View presets | View-scoped shared filter/sort bundles + per-user personal config, applied via toolbar | `preset create/update/list/delete` | [tables-fields.md § View Presets](guides/tables-fields.md) |
|
| View presets | View-scoped shared filter/sort bundles + per-user personal config, applied via toolbar | `preset create/update/list/delete` | [tables-fields.md § View Presets](guides/tables-fields.md) |
|
||||||
| Automation | Event-driven workflows | `workflow *`, `trigger set`, `action add` | [automation-guide.md](guides/automation-guide.md), [trigger-types.md](api-reference/trigger-types.md), [action-types.md](api-reference/action-types.md) |
|
| Automation | Event-driven workflows | `workflow *`, `trigger set`, `action add` | [automation-guide.md](guides/automation-guide.md), [trigger-types.md](api-reference/trigger-types.md), [action-types.md](api-reference/action-types.md) |
|
||||||
| Secrets | API keys for script actions | `secret set/list/delete` | [automation-guide.md § Secrets](guides/automation-guide.md) |
|
| Secrets | API keys for script actions | `secret set/list/delete` | [automation-guide.md § Secrets](guides/automation-guide.md) |
|
||||||
| Execution History | Run results / rerun failures | `runs list/get/rerun` | [automation-guide.md § Testing](guides/automation-guide.md) |
|
| Execution History | Run results / rerun failures | `runs list/get/rerun` | [automation-guide.md § Testing](guides/automation-guide.md) |
|
||||||
| Manual Trigger | Test button-click/webhook | `fire button-click`, `fire webhook` | [automation-guide.md § Testing](guides/automation-guide.md) |
|
| Manual Trigger | Test button-click/webhook | `fire button-click`, `fire webhook` | [automation-guide.md § Testing](guides/automation-guide.md) |
|
||||||
| SQL | Read-only analytics, JOINs | `sql '<sql>'` | [cli-reference.md § sql](guides/cli-reference.md) |
|
|
||||||
| Direct REST API | Integrate external scripts/services; endpoints the CLI lacks; script actions calling back | `curl` / `fetch` + `Authorization: Bearer <PAT>` | [rest-api.md](api-reference/rest-api.md) |
|
| Direct REST API | Integrate external scripts/services; endpoints the CLI lacks; script actions calling back | `curl` / `fetch` + `Authorization: Bearer <PAT>` | [rest-api.md](api-reference/rest-api.md) |
|
||||||
| Plugins | Dynamic plugins, dev whitelist, CSP | `plugin *` | [cli-reference.md § plugin](guides/cli-reference.md) |
|
| Plugins | Dynamic plugins, dev whitelist, CSP | `plugin *` | [cli-reference.md § plugin](guides/cli-reference.md) |
|
||||||
| Subscriptions | Record digest/instant email | `subscription *` | [subscription.md](guides/subscription.md) |
|
| Subscriptions | Record digest/instant email | `subscription *` | [subscription.md](guides/subscription.md) |
|
||||||
@@ -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 |
|
| Send notification | `sendNotification` (in-app) | `sendEmail` (needs SMTP) unless email required |
|
||||||
| **Inspect select-choice constraints** | `field list --with-options`, `field get <tbl> <name>`, `field visible-choices` | `--json` + manual filtering |
|
| **Inspect select-choice constraints** | `field list --with-options`, `field get <tbl> <name>`, `field visible-choices` | `--json` + manual filtering |
|
||||||
| **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) |
|
| **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) |
|
| Read records for later writes | `record get` / `record list` (returns record IDs) | Official-teable SQL APIs (none here) |
|
||||||
| Analytics / JOIN | `sql` with GROUP BY | Fetch all records + compute |
|
| **Attach a remote file to a record** | `record create/update --fields '{"Files":[{"url":"https://…"}]}'` (backend downloads & stores it) | The multipart `uploadAttachment` route or manual signature/notify upload (only needed for local files) |
|
||||||
|
| Analytics / aggregation | `record get --filter` + compute, script action, or grouped grid view | `sql` — this deployment has NO SQL query API (CLI 0.1.5 removed the dead command) |
|
||||||
| Store an API key for scripts | `secret set` → `process.env.NAME` | Hardcoding |
|
| Store an API key for scripts | `secret set` → `process.env.NAME` | Hardcoding |
|
||||||
| Workflow didn't run | `runs list --status failed` → `runs get` | Blind re-activate |
|
| Workflow didn't run | `runs list --status failed` → `runs get` | Blind re-activate |
|
||||||
| Call the instance from external code / a route the CLI lacks | Direct REST: `Authorization: Bearer <PAT>` + routes in [rest-api.md](api-reference/rest-api.md); unknown route → run the nearest CLI command with `--debug` and copy its request | Official teable API docs (different automation contract) |
|
| Call the instance from external code / a route the CLI lacks | Direct REST: `Authorization: Bearer <PAT>` + routes in [rest-api.md](api-reference/rest-api.md); unknown route → run the nearest CLI command with `--debug` and copy its request | Official teable API docs (different automation contract) |
|
||||||
@@ -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).
|
10. **Button field binding**: `options.workflow` is an **object** `{id, name?}`, not a bare id string. The field's `isActive` snapshot can go stale; the API validates the workflow's DB `isActive` at click time (deactivated → 400).
|
||||||
11. **Script sandbox** has NO `fs`/`eval`/`Function`, but has `triggerInput`, `console`, `fetch`, `process.env` (secrets + `AUTOMATION_TOKEN` + `PUBLIC_ORIGIN`), and a controlled `require`. Call back into the instance via `triggerInput._accessToken` + `triggerInput._apiBaseUrl`. **npm deps**: declare with `action add/update --dependencies '[{"name":"axios","version":"1.6.0"}]'` (or `axios@1.6.0,dayjs`). Requires `npm` on the backend **service** PATH — systemd user units default to a PATH that omits the nvm bin dir, so deps fail with `spawn npm ENOENT`; fix by adding `Environment=PATH=<nvm-bin>:/usr/local/sbin:...` to `teable-backend.service`.
|
11. **Script sandbox** has NO `fs`/`eval`/`Function`, but has `triggerInput`, `console`, `fetch`, `process.env` (secrets + `AUTOMATION_TOKEN` + `PUBLIC_ORIGIN`), and a controlled `require`. Call back into the instance via `triggerInput._accessToken` + `triggerInput._apiBaseUrl`. **npm deps**: declare with `action add/update --dependencies '[{"name":"axios","version":"1.6.0"}]'` (or `axios@1.6.0,dayjs`). Requires `npm` on the backend **service** PATH — systemd user units default to a PATH that omits the nvm bin dir, so deps fail with `spawn npm ENOENT`; fix by adding `Environment=PATH=<nvm-bin>:/usr/local/sbin:...` to `teable-backend.service`.
|
||||||
12. **Field options updates go through the convert API** — `field update --options` handles this; the plain PATCH update API strips `options`.
|
12. **Field options updates go through the convert API** — `field update --options` handles this; the plain PATCH update API strips `options`.
|
||||||
|
13. **No SQL query API exists** — the backend has no SQL endpoint; the CLI `sql` command was removed in 0.1.5. Aggregate via `record get --filter` + a script action, or a grouped grid view. Also: `table get/update` are base-scoped (`--base-id`, defaults to the configured base) and `view get/update/delete` + `record get-one` are table-scoped (`--table-id`).
|
||||||
|
|
||||||
## 4. Quick Syntax
|
## 4. Quick Syntax
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
inkreach-teable auth login --endpoint https://table.inkreach.cc --token <PAT> # once
|
inkreach-teable auth login --endpoint https://table.inkreach.cc --token <PAT> # once
|
||||||
inkreach-teable table create --name "Tasks" --fields '[{"name":"Title","type":"text"},{"name":"Status","type":"singleSelect","options":{"choices":["Todo","Done"]}}]'
|
inkreach-teable table create --name "Tasks" --fields '[{"name":"Title","type":"text"},{"name":"Status","type":"singleSelect","options":{"choices":["Todo","Done"]}}]'
|
||||||
|
inkreach-teable record create --table-id tblXXX --fields '{"Title":"a"}' # 单条;批量用 --records '[{"fields":{...}}]'
|
||||||
|
inkreach-teable record create --table-id tblXXX --fields '{"Files":[{"url":"https://…/f.png"}]}' # 附件字段直接吃 URL(服务端下载入库);字符串形式加 --typecast
|
||||||
|
inkreach-teable record list --table-id tblXXX [--filter '<json>'] # 查询/分页(= record get)
|
||||||
|
inkreach-teable record delete --table-id tblXXX recXXX [recYYY ...] # 也可 --record-ids 'recA,recB'
|
||||||
inkreach-teable workflow create --name "On Go click" # → wflXXX
|
inkreach-teable workflow create --name "On Go click" # → wflXXX
|
||||||
inkreach-teable trigger set wflXXX --type buttonClick --table-id tblXXX --field-ids '["fldXXX"]'
|
inkreach-teable trigger set wflXXX --type buttonClick --table-id tblXXX --field-ids '["fldXXX"]'
|
||||||
inkreach-teable action add wflXXX --type script --code 'return triggerInput.record.fields'
|
inkreach-teable action add wflXXX --type script --code 'return triggerInput.record.fields'
|
||||||
@@ -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` |
|
| `runs list` shows failed | Action error | `runs get <wfId> <execId>` → `nodeResults[].error` |
|
||||||
| Script deps fail `spawn npm ENOENT` | `npm` not on service PATH | See Key Constraint 11 |
|
| Script deps fail `spawn npm ENOENT` | `npm` not on service PATH | See Key Constraint 11 |
|
||||||
| Record write 400 "does not meet the current row conditions" | Value violates choice `visibilityFilter` | `field visible-choices` to see allowed options |
|
| Record write 400 "does not meet the current row conditions" | Value violates choice `visibilityFilter` | `field visible-choices` to see allowed options |
|
||||||
|
| `sql: unknown command` / 404 on a SQL endpoint | No SQL query API on this deployment | Aggregate via script action or `record get --filter` |
|
||||||
|
| `record list` works like `record get` | Intentional alias (CLI ≥0.1.5) | — |
|
||||||
| Select-field write "succeeds" but value unchanged (or becomes null) | Value not in choices — without typecast the API 400s; with typecast it resolves to null and no-ops (`mutationApplied=false`), never creates the choice | `field list --with-options` to confirm; `field update --options` to add the choice, then write |
|
| Select-field write "succeeds" but value unchanged (or becomes null) | Value not in choices — without typecast the API 400s; with typecast it resolves to null and no-ops (`mutationApplied=false`), never creates the choice | `field list --with-options` to confirm; `field update --options` to add the choice, then write |
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ Complete reference of all Teable field types, aliases, option shapes, and smart
|
|||||||
| `date` | Date | Date or datetime | No |
|
| `date` | Date | Date or datetime | No |
|
||||||
| `checkbox` | Checkbox | Yes/no toggle | No |
|
| `checkbox` | Checkbox | Yes/no toggle | No |
|
||||||
| `rating` | Rating | Star/numeric rating 1..10 | No (defaults: max=5, star, yellow) |
|
| `rating` | Rating | Star/numeric rating 1..10 | No (defaults: max=5, star, yellow) |
|
||||||
| `attachment` | Attachment | Files, images, documents | No |
|
| `attachment` | Attachment | Files, images, documents; cell values also accept `{"url":"https://…"}` items (server downloads the file) | No || `user` | User | Person/collaborator reference | No |
|
||||||
| `user` | User | Person/collaborator reference | No |
|
|
||||||
| `link` | Link | Reference records in another table | Yes (`foreignTableId`/`foreignTableName`) |
|
| `link` | Link | Reference records in another table | Yes (`foreignTableId`/`foreignTableName`) |
|
||||||
| `lookup` | Lookup | Mirror a field from linked record | Yes (`lookupOptions`) |
|
| `lookup` | Lookup | Mirror a field from linked record | Yes (`lookupOptions`) |
|
||||||
| `rollup` | Rollup | Aggregate linked records (SUM, COUNT, etc.) | Yes (expression + `lookupOptions`) |
|
| `rollup` | Rollup | Aggregate linked records (SUM, COUNT, etc.) | Yes (expression + `lookupOptions`) |
|
||||||
@@ -410,6 +409,7 @@ When `--type` is omitted, the CLI infers the type from the field name:
|
|||||||
| `user` | `{"id":"usrXXX","title":"Alice"}` | `"Alice"` or `"alice@example.com"` |
|
| `user` | `{"id":"usrXXX","title":"Alice"}` | `"Alice"` or `"alice@example.com"` |
|
||||||
| `link` (single) | `{"id":"recXXX"}` or `[{"id":"recXXX"}]` | `"Record Display Name"` |
|
| `link` (single) | `{"id":"recXXX"}` or `[{"id":"recXXX"}]` | `"Record Display Name"` |
|
||||||
| `link` (multi) | `[{"id":"recA"},{"id":"recB"}]` | `["Record A","Record B"]` |
|
| `link` (multi) | `[{"id":"recA"},{"id":"recB"}]` | `["Record A","Record B"]` |
|
||||||
|
| `attachment` | `[{"url":"https://…/f.png","name":"f.png"}]` (V1 legacy bases: token items only) | `"https://…/f.png"` or `"https://…/a.png,actXXX"` (comma-separated urls/ids) |
|
||||||
|
|
||||||
**When to use**: always use `--typecast` when creating/updating records with user or link fields — it eliminates manual ID lookups.
|
**When to use**: always use `--typecast` when creating/updating records with user or link fields — it eliminates manual ID lookups.
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ Verified route-for-route against `src/lib/client.ts` (the CLI's HTTP layer).
|
|||||||
List-query params (objects are passed **JSON-stringified**; repeated keys use
|
List-query params (objects are passed **JSON-stringified**; repeated keys use
|
||||||
`key[]`): `filter` (JSON), `projection` (JSON array), `search[]` (keyword —
|
`key[]`): `filter` (JSON), `projection` (JSON array), `search[]` (keyword —
|
||||||
MUST be the `search[]` tuple form, plain `search=` is ignored), `skip` / `take`
|
MUST be the `search[]` tuple form, plain `search=` is ignored), `skip` / `take`
|
||||||
(pagination; no `orderBy` on this fork — sort via view or `sql`). Response:
|
(pagination; no `orderBy` on this fork — sort via a view). Response:
|
||||||
`{ records: [{id, fields}], total? }`.
|
`{ records: [{id, fields}], total? }`.
|
||||||
|
|
||||||
### Fields
|
### Fields
|
||||||
@@ -83,7 +83,6 @@ MUST be the `search[]` tuple form, plain `search=` is ignored), `skip` / `take`
|
|||||||
|
|
||||||
| Method & route | Purpose |
|
| Method & route | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `POST /api/base/{bid}/sql` | Read-only SQL (`{sql}`; JOIN/GROUP BY; no record ids) |
|
|
||||||
| `GET /api/table/{tid}/aggregation/row-count` | Total row count (`{"rowCount":N}` — verified) |
|
| `GET /api/table/{tid}/aggregation/row-count` | Total row count (`{"rowCount":N}` — verified) |
|
||||||
| `GET /api/table/{tid}/aggregation…` | Stats family (group-points, calendar-daily, search-count…) — see swagger for params |
|
| `GET /api/table/{tid}/aggregation…` | Stats family (group-points, calendar-daily, search-count…) — see swagger for params |
|
||||||
| `GET`/`POST` `/api/access-token`, `DELETE /api/access-token/{id}` | PAT management |
|
| `GET`/`POST` `/api/access-token`, `DELETE /api/access-token/{id}` | PAT management |
|
||||||
@@ -153,6 +152,16 @@ const res = await fetch(triggerInput._apiBaseUrl + "/api/table/tblB/record", {
|
|||||||
`401` = bad/expired token, `403` = scope or base-role too low.
|
`401` = bad/expired token, `403` = scope or base-role too low.
|
||||||
6. To see the exact request the CLI sends for any operation, run it with
|
6. To see the exact request the CLI sends for any operation, run it with
|
||||||
`--debug` (prints method/url/body) — the fastest way to learn a route.
|
`--debug` (prints method/url/body) — the fastest way to learn a route.
|
||||||
|
7. **Attachment fields accept URLs** (2026-10): pass `{"url":"https://…","name":"…"}` items
|
||||||
|
(or a URL string with `typecast:true` — note `typecast` is a BODY key) in
|
||||||
|
record create/update `fields`, and the backend downloads the file
|
||||||
|
(SSRF-guarded, size-capped) into attachment storage — response items come
|
||||||
|
back with real `token`/`presignedUrl`. A failed download rejects the whole
|
||||||
|
request (400); nothing is silently dropped. V1 (legacy) bases need
|
||||||
|
`typecast:true` for the string form, V2 bases accept both. The dedicated
|
||||||
|
`POST /api/table/{tid}/record/{rid}/{fid}/uploadAttachment` (multipart,
|
||||||
|
`file` or `fileUrl`) is the older single-record path — the plain record
|
||||||
|
routes now cover URL imports.
|
||||||
|
|
||||||
## 5. Full Route Inventory (this guide ≠ the whole API)
|
## 5. Full Route Inventory (this guide ≠ the whole API)
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,15 @@ inkreach-teable auth token delete <id>
|
|||||||
|
|
||||||
- `auth token create` returns the full token **only once**. Default scopes: `automation|read,automation|create,automation|update,automation|delete,table|read,record|read,record|create,record|update`.
|
- `auth token create` returns the full token **only once**. Default scopes: `automation|read,automation|create,automation|update,automation|delete,table|read,record|read,record|create,record|update`.
|
||||||
|
|
||||||
|
## space / base
|
||||||
|
|
||||||
|
```bash
|
||||||
|
inkreach-teable space list
|
||||||
|
inkreach-teable space create --name "<name>"
|
||||||
|
inkreach-teable base create --space-id <spaceId> --name "<name>"
|
||||||
|
inkreach-teable base delete <baseId> # DESTRUCTIVE: soft-deletes all tables in it
|
||||||
|
```
|
||||||
|
|
||||||
## base / table / field / record / view
|
## base / table / field / record / view
|
||||||
|
|
||||||
These resources use the official-teable-compatible API. Full guide: [tables-fields.md](tables-fields.md).
|
These resources use the official-teable-compatible API. Full guide: [tables-fields.md](tables-fields.md).
|
||||||
@@ -44,7 +53,7 @@ inkreach-teable table update <tableId> --name <n>
|
|||||||
inkreach-teable table delete <tableId>
|
inkreach-teable table delete <tableId>
|
||||||
|
|
||||||
# field (get/update/visible-choices take <tableId> <fieldIdOrName> — id or display name, auto-resolved)
|
# field (get/update/visible-choices take <tableId> <fieldIdOrName> — id or display name, auto-resolved)
|
||||||
inkreach-teable field list <tableId> [--with-options]
|
inkreach-teable field list <tableId> # --table-id <id> also accepted [--with-options]
|
||||||
# --with-options: OPTIONS summary column + per-choice visibility conditions for select fields
|
# --with-options: OPTIONS summary column + per-choice visibility conditions for select fields
|
||||||
inkreach-teable field get <tableId> <fieldIdOrName>
|
inkreach-teable field get <tableId> <fieldIdOrName>
|
||||||
# human mode renders select choices with their visibilityFilter conditions; --json for raw
|
# human mode renders select choices with their visibilityFilter conditions; --json for raw
|
||||||
@@ -56,12 +65,15 @@ inkreach-teable field visible-choices <tableId> <recordId> <fieldIdOrName>
|
|||||||
inkreach-teable field delete <tableId> <fieldId>
|
inkreach-teable field delete <tableId> <fieldId>
|
||||||
|
|
||||||
# record
|
# record
|
||||||
inkreach-teable record get --table-id <tid> [--filter '<json>'] [--projection '<json>'] [--page-size <n>] [--page <n>] [--search <kw>]
|
inkreach-teable record get --table-id <tid> [--filter '<json>'] [--projection '<json>'] [--page-size <n>|--take <n>] [--page <n>|--skip <n>] [--search <kw>] [--record-id <rid>]
|
||||||
inkreach-teable record get-one <recordId>
|
inkreach-teable record list --table-id <tid> [...] # alias of record get
|
||||||
|
inkreach-teable record get-one <recordId> --table-id <tid>
|
||||||
inkreach-teable record create --table-id <tid> --fields '<json>' # single
|
inkreach-teable record create --table-id <tid> --fields '<json>' # single
|
||||||
inkreach-teable record create --table-id <tid> --records '<json>' # batch
|
inkreach-teable record create --table-id <tid> --records '<json>' # batch
|
||||||
inkreach-teable record update --table-id <tid> --records '<json>'
|
inkreach-teable record update --table-id <tid> --records '<json>'
|
||||||
inkreach-teable record delete --table-id <tid> --record-ids <csv>
|
# shared write flags: [--typecast] [--field-key-type <name|id|dbFieldName>]
|
||||||
|
# [--order '{"viewId":"viwX","anchorId":"recY","position":"before|after"}'] — insert/move in a manual-sort view
|
||||||
|
inkreach-teable record delete --table-id <tid> <recordId> [recordId ...] # or --record-ids <csv>
|
||||||
|
|
||||||
# view
|
# view
|
||||||
inkreach-teable view list <tableId>
|
inkreach-teable view list <tableId>
|
||||||
@@ -181,18 +193,6 @@ inkreach-teable subscription send <subscriptionId>
|
|||||||
Record digest/instant email subscriptions. Options (`--filter`, `--send-mode`,
|
Record digest/instant email subscriptions. Options (`--filter`, `--send-mode`,
|
||||||
`--schedule-*`, `--trigger-on-enter`, …) and semantics: [subscription.md](subscription.md).
|
`--schedule-*`, `--trigger-on-enter`, …) and semantics: [subscription.md](subscription.md).
|
||||||
|
|
||||||
## sql
|
|
||||||
|
|
||||||
```bash
|
|
||||||
inkreach-teable sql '<SELECT ...>' [--base-id <id>]
|
|
||||||
```
|
|
||||||
|
|
||||||
Read-only. Must use `dbTableName`/`dbFieldName` (from `table get`/`field get`), double-quote identifiers, add `LIMIT 100`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
inkreach-teable sql 'SELECT "name" FROM "bseXXX"."tbl_abc" LIMIT 10'
|
|
||||||
```
|
|
||||||
|
|
||||||
## plugin
|
## plugin
|
||||||
|
|
||||||
Manage plugins and developer whitelist.
|
Manage plugins and developer whitelist.
|
||||||
@@ -202,10 +202,14 @@ Manage plugins and developer whitelist.
|
|||||||
inkreach-teable plugin list
|
inkreach-teable plugin list
|
||||||
|
|
||||||
# Register a URL-served plugin (runtimeType=url; status published immediately)
|
# Register a URL-served plugin (runtimeType=url; status published immediately)
|
||||||
# positions: "base"|"panel"|"view"|"dashboard"|"contextMenu"
|
# positions: "base"|"panel"|"view"|"dashboard"|"contextMenu"|"route"
|
||||||
inkreach-teable plugin register --url <url> --name <name> [--positions '["panel"]'] [--logo <url>] [--description <d>]
|
inkreach-teable plugin register --url <url> --name <name> [--positions '["panel"]'] [--logo <url>] [--description <d>]
|
||||||
# Bundle (upload) plugins use a different flow — see skill guides/tables-fields.md
|
# Bundle (IIFE upload) plugins use a different flow — use the
|
||||||
# or api docs: create kebab-id plugin (developer whitelist) → upload → admin publish.
|
# `inkreach-teable-plugin` skill instead: teable-plugin create/dev/deploy
|
||||||
|
# (scaffold → watch+hot-reload dev loop → multi-asset upload with chunks,
|
||||||
|
# source maps and css). Since 2026-10: POST /api/plugin (custom kebab-id,
|
||||||
|
# plugin_developer whitelist) and upload-bundle both accept PATs, and
|
||||||
|
# position "route" renders full-page apps at /base/:baseId/p/:pluginId.
|
||||||
|
|
||||||
# Developer whitelist (admin only)
|
# Developer whitelist (admin only)
|
||||||
inkreach-teable plugin dev list
|
inkreach-teable plugin dev list
|
||||||
@@ -217,6 +221,8 @@ inkreach-teable plugin install --plugin <pluginId> --name "My Plugin" --position
|
|||||||
# ⚠️ For view/dashboard-position plugins, run `plugin config enable` BEFORE
|
# ⚠️ For view/dashboard-position plugins, run `plugin config enable` BEFORE
|
||||||
# install, or the install API returns 403 RESTRICTED_RESOURCE
|
# install, or the install API returns 403 RESTRICTED_RESOURCE
|
||||||
# 'Plugin is not enabled for this base'.
|
# 'Plugin is not enabled for this base'.
|
||||||
|
# position "route" mounts a full-page app at /base/:baseId/p/:pluginId
|
||||||
|
# (no enable gate — the URL is the access point).
|
||||||
inkreach-teable plugin installs # list installs in current base
|
inkreach-teable plugin installs # list installs in current base
|
||||||
inkreach-teable plugin uninstall --install <installId>
|
inkreach-teable plugin uninstall --install <installId>
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ With select options: `"Status:sel:Todo,Doing,Done"`, `"Tags:mul:Bug,Feature,UI"`
|
|||||||
### Update & Delete
|
### Update & Delete
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
inkreach-teable table update <tableId> --name "<newName>" [--description <d>]
|
inkreach-teable table update <tableId> --name "<newName>" [--base-id <id>]
|
||||||
inkreach-teable table delete <tableId>
|
inkreach-teable table delete <tableId>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -70,10 +70,11 @@ inkreach-teable table delete <tableId>
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
inkreach-teable table list [--base-id <id>]
|
inkreach-teable table list [--base-id <id>]
|
||||||
inkreach-teable table get <tableId>
|
inkreach-teable table get <tableId> [--base-id <id>]
|
||||||
```
|
```
|
||||||
|
|
||||||
`table get` returns `dbTableName` — the internal SQL table name used in `sql` queries (e.g., `SELECT * FROM "bseXXX"."tbl_abc"`).
|
`table get` returns `dbTableName` — the internal PG table name (useful for
|
||||||
|
maintainer-level psql inspection; there is NO SQL query API on this deployment).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -162,8 +163,8 @@ Single/multiple-select choices may carry a `visibilityFilter` making them visibl
|
|||||||
inkreach-teable record get --table-id <tid> [--filter '<json>'] [--projection '<json>'] \
|
inkreach-teable record get --table-id <tid> [--filter '<json>'] [--projection '<json>'] \
|
||||||
[--page-size <n>] [--page <n>] [--search <kw>]
|
[--page-size <n>] [--page <n>] [--search <kw>]
|
||||||
|
|
||||||
# Single record by ID
|
# Single record by ID (table-scoped route)
|
||||||
inkreach-teable record get-one <recordId>
|
inkreach-teable record get-one <recordId> --table-id <tid>
|
||||||
```
|
```
|
||||||
|
|
||||||
| Flag | Description |
|
| Flag | Description |
|
||||||
@@ -250,6 +251,25 @@ inkreach-teable record create --table-id tblXXX --fields '{"Assignee":{"id":"usr
|
|||||||
inkreach-teable record create --table-id tblXXX --fields '{"Assignee":"Alice"}' --typecast
|
inkreach-teable record create --table-id tblXXX --fields '{"Assignee":"Alice"}' --typecast
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Attachment URL import** (2026-10): attachment fields accept http(s) URLs — the
|
||||||
|
backend downloads the file (SSRF-guarded, size-capped) and stores it as a real
|
||||||
|
attachment with thumbnail. Two forms:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Object form — works without --typecast; name is optional (derived from the URL)
|
||||||
|
inkreach-teable record create --table-id tblXXX \
|
||||||
|
--fields '{"Files":[{"url":"https://example.com/f.png","name":"report.png"}]}'
|
||||||
|
|
||||||
|
# String form — needs --typecast; comma-separates URLs and existing actXXX ids
|
||||||
|
inkreach-teable record create --table-id tblXXX --typecast \
|
||||||
|
--fields '{"Files":"https://example.com/a.png,https://example.com/b.png"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes: only `http:`/`https:` URLs; URLs containing commas cannot be expressed in
|
||||||
|
the string form (use the object form); a failed download rejects the whole
|
||||||
|
request with 400 (nothing is silently dropped); V1 (legacy) bases require
|
||||||
|
`--typecast` for the string form, V2 bases accept both forms directly.
|
||||||
|
|
||||||
**`--records` format** (batch): `{"header":["Field1","Field2"],"records":[["val1","val2"],["val3","val4"]]}`
|
**`--records` format** (batch): `{"header":["Field1","Field2"],"records":[["val1","val2"],["val3","val4"]]}`
|
||||||
|
|
||||||
### Update
|
### Update
|
||||||
@@ -260,7 +280,7 @@ inkreach-teable record update --table-id <tid> --records '<json>'
|
|||||||
|
|
||||||
**`--records` format**: `[{"id":"recXXX","fields":{"FieldName":"newValue"}}]` — array of `{id, fields}` objects. Batch up to 2000.
|
**`--records` format**: `[{"id":"recXXX","fields":{"FieldName":"newValue"}}]` — array of `{id, fields}` objects. Batch up to 2000.
|
||||||
|
|
||||||
**Record reordering**: pass `--order` with `viewId`, `anchorId`, `position`:
|
**Record reordering**: pass `--order` with `viewId`, `anchorId`, `position` (works on both `record create` — insert at position — and `record update` — move existing records; only meaningful in a view without an explicit sort):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
inkreach-teable record update --table-id tblXXX \
|
inkreach-teable record update --table-id tblXXX \
|
||||||
@@ -268,10 +288,17 @@ inkreach-teable record update --table-id tblXXX \
|
|||||||
--order '{"viewId":"viwXXX","anchorId":"recYYY","position":"before"}'
|
--order '{"viewId":"viwXXX","anchorId":"recYYY","position":"before"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**`--field-key-type <name|id|dbFieldName>`** (default `name`): read `--fields`/`--records` keys as field ids instead of names — useful when field names contain characters that are awkward in JSON keys, or when you already have `fldXXX` ids from `field list --json`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
inkreach-teable record create --table-id tblXXX --field-key-type id \
|
||||||
|
--fields '{"fldXXXX":"hello"}'
|
||||||
|
```
|
||||||
|
|
||||||
### Delete
|
### Delete
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
inkreach-teable record delete --table-id <tid> --record-ids "recA,recB,recC"
|
inkreach-teable record delete --table-id <tid> recA recB recC # or --record-ids "recA,recB,recC"
|
||||||
```
|
```
|
||||||
|
|
||||||
Max 1000 IDs per call. Irreversible.
|
Max 1000 IDs per call. Irreversible.
|
||||||
@@ -284,7 +311,7 @@ Max 1000 IDs per call. Irreversible.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
inkreach-teable view list <tableId>
|
inkreach-teable view list <tableId>
|
||||||
inkreach-teable view get <viewId>
|
inkreach-teable view get <viewId> --table-id <tid>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Create
|
### Create
|
||||||
@@ -304,8 +331,8 @@ inkreach-teable view create --table-id <tid> --name "<name>" [--type <t>]
|
|||||||
### Update (configure filters, sorts, grouping)
|
### Update (configure filters, sorts, grouping)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
inkreach-teable view update <viewId> \
|
inkreach-teable view update <viewId> --table-id <tid> \
|
||||||
[--name "<n>"] \
|
[--name "<n>"] [--description "<d>"] \
|
||||||
[--filter '<json>'] \
|
[--filter '<json>'] \
|
||||||
[--sort '<json>'] \
|
[--sort '<json>'] \
|
||||||
[--group '<json>'] \
|
[--group '<json>'] \
|
||||||
@@ -314,7 +341,7 @@ inkreach-teable view update <viewId> \
|
|||||||
|
|
||||||
**Filters**: `{"conjunction":"and","filterSet":[{"fieldId":"fldXXX","operator":"is","value":"Done"}]}`
|
**Filters**: `{"conjunction":"and","filterSet":[{"fieldId":"fldXXX","operator":"is","value":"Done"}]}`
|
||||||
|
|
||||||
**Sorts**: `[{"fieldId":"fldXXX","order":"asc"}]`
|
**Sorts**: `[{"fieldId":"fldXXX","order":"asc"}]` (the CLI wraps it into `{sortObjs:[...]}` for the API)
|
||||||
|
|
||||||
**Group**: `[{"fieldId":"fldXXX","order":"asc"}]`
|
**Group**: `[{"fieldId":"fldXXX","order":"asc"}]`
|
||||||
|
|
||||||
@@ -323,7 +350,7 @@ inkreach-teable view update <viewId> \
|
|||||||
### Delete
|
### Delete
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
inkreach-teable view delete <viewId>
|
inkreach-teable view delete <viewId> --table-id <tid>
|
||||||
```
|
```
|
||||||
|
|
||||||
Deleting a view does NOT delete the table or its data — only the view configuration.
|
Deleting a view does NOT delete the table or its data — only the view configuration.
|
||||||
@@ -377,7 +404,7 @@ inkreach-teable preset delete <presetId> --table-id tblXxx
|
|||||||
| `singleSelect` | `"Option Name"` | (string) |
|
| `singleSelect` | `"Option Name"` | (string) |
|
||||||
| `multipleSelect` | `["Tag A","Tag B"]` | (array of strings) |
|
| `multipleSelect` | `["Tag A","Tag B"]` | (array of strings) |
|
||||||
| `rating` | `3` | Integer 1..max |
|
| `rating` | `3` | Integer 1..max |
|
||||||
| `attachment` | `[{"name":"f.png","token":"xxx"}]` | Always array of objects; update **replaces all** |
|
| `attachment` | `[{"name":"f.png","token":"xxx"}]` | Always array of objects; update **replaces all**. Also accepts `[{"url":"https://…","name":"f.png"}]` (server downloads the file) and, with `--typecast`, a plain URL string — see attachment URL import below |
|
||||||
| `user` | `{"id":"usrXXX","title":"Alice"}` | With `--typecast`: just `"Alice"` or `"alice@example.com"` |
|
| `user` | `{"id":"usrXXX","title":"Alice"}` | With `--typecast`: just `"Alice"` or `"alice@example.com"` |
|
||||||
| `link` | `[{"id":"recXXX"}]` | With `--typecast`: just `"Linked Record Name"` |
|
| `link` | `[{"id":"recXXX"}]` | With `--typecast`: just `"Linked Record Name"` |
|
||||||
| `formula` / `rollup` / `lookup` | Read-only | Cannot be set via record create/update |
|
| `formula` / `rollup` / `lookup` | Read-only | Cannot be set via record create/update |
|
||||||
@@ -403,4 +430,5 @@ inkreach-teable preset delete <presetId> --table-id tblXxx
|
|||||||
| View data in kanban board | `view create --type kanban` then `view update --group` | Creating special columns |
|
| View data in kanban board | `view create --type kanban` then `view update --group` | Creating special columns |
|
||||||
| Set datetime in a date field | `"2025-06-15T14:30:00.000Z"` | `"2025-06-15 14:30"` (non-ISO; may be rejected) |
|
| Set datetime in a date field | `"2025-06-15T14:30:00.000Z"` | `"2025-06-15 14:30"` (non-ISO; may be rejected) |
|
||||||
| Update attachments | Always pass the full array (replaces all) | Trying to add/remove individual attachments |
|
| Update attachments | Always pass the full array (replaces all) | Trying to add/remove individual attachments |
|
||||||
|
| Attach a file by URL | `record create --fields '{"Files":[{"url":"https://…/f.png"}]}'` (object form, no typecast needed) or `--typecast --fields '{"Files":"https://…/f.png"}'` | Uploading via the attachments signature/notify endpoints first (only needed for local files) |
|
||||||
| Batch insert 500 records | One `record create --records` call | 500 separate `record create` calls |
|
| Batch insert 500 records | One `record create --records` call | 500 separate `record create` calls |
|
||||||
|
|||||||
+1
-2
@@ -10,7 +10,7 @@ import { pluginCommand } from './commands/plugin';
|
|||||||
import { authCommand } from './commands/auth';
|
import { authCommand } from './commands/auth';
|
||||||
import { workflowCommand, triggerCommand, actionCommand } from './commands/workflow';
|
import { workflowCommand, triggerCommand, actionCommand } from './commands/workflow';
|
||||||
import { baseResourceCommands } from './commands/resources';
|
import { baseResourceCommands } from './commands/resources';
|
||||||
import { secretCommand, runsCommand, fireCommand, sqlCommand } from './commands/extras';
|
import { secretCommand, runsCommand, fireCommand } from './commands/extras';
|
||||||
import { subscriptionCommand } from './commands/subscription';
|
import { subscriptionCommand } from './commands/subscription';
|
||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
@@ -42,7 +42,6 @@ program.addCommand(subscriptionCommand());
|
|||||||
program.addCommand(secretCommand());
|
program.addCommand(secretCommand());
|
||||||
program.addCommand(runsCommand());
|
program.addCommand(runsCommand());
|
||||||
program.addCommand(fireCommand());
|
program.addCommand(fireCommand());
|
||||||
program.addCommand(sqlCommand());
|
|
||||||
|
|
||||||
program.parseAsync(process.argv).catch((err) => {
|
program.parseAsync(process.argv).catch((err) => {
|
||||||
process.stderr.write(`Fatal: ${err instanceof Error ? err.message : String(err)}\n`);
|
process.stderr.write(`Fatal: ${err instanceof Error ? err.message : String(err)}\n`);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
* `inkreach-teable secret` — manage workflow automation secrets.
|
* `inkreach-teable secret` — manage workflow automation secrets.
|
||||||
* `inkreach-teable runs` — execution history.
|
* `inkreach-teable runs` — execution history.
|
||||||
* `inkreach-teable fire` — manual trigger testing (button-click / webhook).
|
* `inkreach-teable fire` — manual trigger testing (button-click / webhook).
|
||||||
* `inkreach-teable sql` — read-only SQL queries.
|
|
||||||
*/
|
*/
|
||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import { run, requireBaseId } from '../lib/ctx';
|
import { run, requireBaseId } from '../lib/ctx';
|
||||||
@@ -282,49 +281,3 @@ export function fireCommand(): Command {
|
|||||||
|
|
||||||
return cmd;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===================== sql =====================
|
|
||||||
|
|
||||||
export function sqlCommand(): Command {
|
|
||||||
const cmd = new Command('sql');
|
|
||||||
cmd.description('Execute a read-only SQL query (SELECT only)');
|
|
||||||
cmd.option('--base-id <id>', 'Base id (overrides default)');
|
|
||||||
cmd.argument('<sql>', 'SQL SELECT statement');
|
|
||||||
|
|
||||||
cmd.action(
|
|
||||||
run(async (ctx, args, o) => {
|
|
||||||
const sql = args[0] as string;
|
|
||||||
const opts = o as { baseId?: string };
|
|
||||||
const baseId = requireBaseId(ctx, opts.baseId);
|
|
||||||
const rows = (await ctx.client.sql(baseId, sql)) as unknown[];
|
|
||||||
if (ctx.json) {
|
|
||||||
printJson(rows);
|
|
||||||
} else {
|
|
||||||
if (!rows.length) {
|
|
||||||
process.stdout.write('No rows returned.\n');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const first = rows[0];
|
|
||||||
if (typeof first === 'object' && first !== null && !Array.isArray(first)) {
|
|
||||||
const keys = Object.keys(first as Record<string, unknown>);
|
|
||||||
printTable(
|
|
||||||
keys.map((k) => ({ key: k, header: k })),
|
|
||||||
rows.map((r) => {
|
|
||||||
const obj = r as Record<string, unknown>;
|
|
||||||
const row: Record<string, unknown> = {};
|
|
||||||
for (const k of keys) {
|
|
||||||
row[k] = truncate(obj[k], 40);
|
|
||||||
}
|
|
||||||
return row;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
process.stdout.write(`\n${rows.length} row(s).\n`);
|
|
||||||
} else {
|
|
||||||
rows.forEach((r) => process.stdout.write(JSON.stringify(r) + '\n'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return cmd;
|
|
||||||
}
|
|
||||||
|
|||||||
+300
-38
@@ -20,6 +20,55 @@ import {
|
|||||||
} from '../lib/field-normalize';
|
} from '../lib/field-normalize';
|
||||||
import { formatFieldDetail, summarizeFieldOptions, choiceRows } from '../lib/field-display';
|
import { formatFieldDetail, summarizeFieldOptions, choiceRows } from '../lib/field-display';
|
||||||
|
|
||||||
|
const FIELD_KEY_TYPES = ['name', 'id', 'dbFieldName'] as const;
|
||||||
|
type FieldKeyTypeCli = (typeof FIELD_KEY_TYPES)[number];
|
||||||
|
|
||||||
|
/** Validate --field-key-type (backend body key `fieldKeyType`, default "name"). */
|
||||||
|
function parseFieldKeyType(raw: string | undefined): FieldKeyTypeCli | undefined {
|
||||||
|
if (raw === undefined) return undefined;
|
||||||
|
if (!(FIELD_KEY_TYPES as readonly string[]).includes(raw)) {
|
||||||
|
throw Object.assign(
|
||||||
|
new Error(`--field-key-type must be one of: ${FIELD_KEY_TYPES.join(', ')}`),
|
||||||
|
{ code: 'BAD_FIELD_KEY_TYPE' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return raw as FieldKeyTypeCli;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecordOrderRo = { viewId: string; anchorId: string; position: 'before' | 'after' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate --order (backend body key `order` on record create/update):
|
||||||
|
* place the written records before/after an anchor record in a
|
||||||
|
* manually-sorted view.
|
||||||
|
*/
|
||||||
|
function parseRecordOrder(raw: string): RecordOrderRo {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
throw Object.assign(
|
||||||
|
new Error('--order must be JSON: {"viewId":"viwXXX","anchorId":"recYYY","position":"before"}'),
|
||||||
|
{ code: 'BAD_JSON' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const obj = parsed as Record<string, unknown>;
|
||||||
|
const { viewId, anchorId, position } = obj ?? {};
|
||||||
|
if (
|
||||||
|
typeof viewId !== 'string' ||
|
||||||
|
typeof anchorId !== 'string' ||
|
||||||
|
(position !== 'before' && position !== 'after')
|
||||||
|
) {
|
||||||
|
throw Object.assign(
|
||||||
|
new Error(
|
||||||
|
'--order needs string viewId + anchorId and position "before"|"after", e.g. {"viewId":"viwXXX","anchorId":"recYYY","position":"before"}'
|
||||||
|
),
|
||||||
|
{ code: 'BAD_ORDER' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { viewId, anchorId, position };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch a field by ID or display name. A direct ID hit needs no extra call;
|
* Fetch a field by ID or display name. A direct ID hit needs no extra call;
|
||||||
* a 400/404 miss (the API rejects non-ID paths) falls back to resolving the
|
* a 400/404 miss (the API rejects non-ID paths) falls back to resolving the
|
||||||
@@ -50,7 +99,7 @@ async function fieldNameMap(client: Client, tableId: string): Promise<Record<str
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function baseResourceCommands(): Command[] {
|
export function baseResourceCommands(): Command[] {
|
||||||
return [baseCmd(), tableCmd(), fieldCmd(), recordCmd(), viewCmd(), presetCmd()];
|
return [baseCmd(), spaceCmd(), tableCmd(), fieldCmd(), recordCmd(), viewCmd(), presetCmd()];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===================== base =====================
|
// ===================== base =====================
|
||||||
@@ -82,6 +131,35 @@ function baseCmd(): Command {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('create')
|
||||||
|
.description('Create a base in a space')
|
||||||
|
.requiredOption('--space-id <id>', 'Space id (see `space list`)')
|
||||||
|
.requiredOption('--name <name>', 'New base name')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, _a, o) => {
|
||||||
|
const opts = o as { spaceId: string; name: string };
|
||||||
|
const base = await ctx.client.createBase(opts.spaceId, opts.name);
|
||||||
|
if (ctx.json) printJson(base);
|
||||||
|
else {
|
||||||
|
const b = base as Record<string, unknown>;
|
||||||
|
printKv([['id', b.id], ['name', b.name]]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('delete <baseId>')
|
||||||
|
.description('Soft-delete a base (DESTRUCTIVE: hides all its tables)')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, args) => {
|
||||||
|
const baseId = args[0] as string;
|
||||||
|
await ctx.client.deleteBase(baseId);
|
||||||
|
if (ctx.json) printJson({ ok: true, id: baseId });
|
||||||
|
else process.stdout.write(`✓ Base ${baseId} deleted (soft)\n`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
cmd
|
cmd
|
||||||
.command('get <baseId>')
|
.command('get <baseId>')
|
||||||
.description('Get base details')
|
.description('Get base details')
|
||||||
@@ -100,6 +178,54 @@ function baseCmd(): Command {
|
|||||||
return cmd;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===================== space =====================
|
||||||
|
|
||||||
|
function spaceCmd(): Command {
|
||||||
|
const cmd = new Command('space');
|
||||||
|
cmd.description('Manage spaces (top-level containers for bases)');
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('list')
|
||||||
|
.description('List spaces you can access')
|
||||||
|
.action(
|
||||||
|
run(async (ctx) => {
|
||||||
|
const list = (await ctx.client.listSpaces()) as Array<Record<string, unknown>>;
|
||||||
|
if (ctx.json) printJson(list);
|
||||||
|
else {
|
||||||
|
if (!list.length) {
|
||||||
|
process.stdout.write('No spaces found.\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
printTable(
|
||||||
|
[
|
||||||
|
{ key: 'id', header: 'ID' },
|
||||||
|
{ key: 'name', header: 'NAME' },
|
||||||
|
],
|
||||||
|
list.map((sp) => ({ id: truncate(sp.id), name: truncate(sp.name, 40) }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command('create')
|
||||||
|
.description('Create a space')
|
||||||
|
.requiredOption('--name <name>', 'New space name')
|
||||||
|
.action(
|
||||||
|
run(async (ctx, _a, o) => {
|
||||||
|
const opts = o as { name: string };
|
||||||
|
const sp = await ctx.client.createSpace(opts.name);
|
||||||
|
if (ctx.json) printJson(sp);
|
||||||
|
else {
|
||||||
|
const s2 = sp as Record<string, unknown>;
|
||||||
|
printKv([['id', s2.id], ['name', s2.name]]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
// ===================== table =====================
|
// ===================== table =====================
|
||||||
|
|
||||||
function tableCmd(): Command {
|
function tableCmd(): Command {
|
||||||
@@ -140,10 +266,12 @@ function tableCmd(): Command {
|
|||||||
cmd
|
cmd
|
||||||
.command('get <tableId>')
|
.command('get <tableId>')
|
||||||
.description('Get table details')
|
.description('Get table details')
|
||||||
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
.action(
|
.action(
|
||||||
run(async (ctx, args) => {
|
run(async (ctx, args, o) => {
|
||||||
const tableId = args[0] as string;
|
const tableId = args[0] as string;
|
||||||
const table = await ctx.client.getTable(tableId);
|
const opts = o as { baseId?: string };
|
||||||
|
const table = await ctx.client.getTable(tableId, requireBaseId(ctx, opts.baseId));
|
||||||
if (ctx.json) printJson(table);
|
if (ctx.json) printJson(table);
|
||||||
else {
|
else {
|
||||||
const t = table as Record<string, unknown>;
|
const t = table as Record<string, unknown>;
|
||||||
@@ -234,16 +362,18 @@ function fieldCmd(): Command {
|
|||||||
cmd.description('Manage fields (columns) in a table');
|
cmd.description('Manage fields (columns) in a table');
|
||||||
|
|
||||||
cmd
|
cmd
|
||||||
.command('list <tableId>')
|
.command('list [tableId]')
|
||||||
.description('List fields in a table')
|
.description('List fields in a table')
|
||||||
|
.option('--table-id <id>', 'Table id (alternative to the positional argument)')
|
||||||
.option(
|
.option(
|
||||||
'--with-options',
|
'--with-options',
|
||||||
'Add an OPTIONS summary column and expand select choices with their visibility conditions'
|
'Add an OPTIONS summary column and expand select choices with their visibility conditions'
|
||||||
)
|
)
|
||||||
.action(
|
.action(
|
||||||
run(async (ctx, args, o) => {
|
run(async (ctx, args, o) => {
|
||||||
const opts = o as { withOptions?: boolean };
|
const opts = o as { withOptions?: boolean; tableId?: string };
|
||||||
const tableId = args[0] as string;
|
const tableId = opts.tableId || (args[0] as string);
|
||||||
|
if (!tableId) throw new Error('Pass a tableId (positional or --table-id)');
|
||||||
const list = (await ctx.client.listFields(tableId)) as Array<Record<string, unknown>>;
|
const list = (await ctx.client.listFields(tableId)) as Array<Record<string, unknown>>;
|
||||||
if (ctx.json) printJson(list);
|
if (ctx.json) printJson(list);
|
||||||
else {
|
else {
|
||||||
@@ -303,13 +433,23 @@ function fieldCmd(): Command {
|
|||||||
);
|
);
|
||||||
|
|
||||||
cmd
|
cmd
|
||||||
.command('get <tableId> <fieldIdOrName>')
|
.command('get [tableId] [fieldIdOrName]')
|
||||||
.description(
|
.description(
|
||||||
'Get field details by id or name (full options; select choices rendered with visibility conditions)'
|
'Get field details by id or name (full options; select choices rendered with visibility conditions)'
|
||||||
)
|
)
|
||||||
|
.option('--table-id <id>', 'Table id (alternative to the positional argument)')
|
||||||
.action(
|
.action(
|
||||||
run(async (ctx, args) => {
|
run(async (ctx, args, o) => {
|
||||||
const [tableId, fieldIdOrName] = args as [string, string];
|
const opts = o as { tableId?: string };
|
||||||
|
let tableId = opts.tableId || (args[0] as string);
|
||||||
|
let fieldIdOrName = args[1] as string;
|
||||||
|
// `field get <fieldIdOrName> --table-id X` — one positional + flag
|
||||||
|
if (!fieldIdOrName) {
|
||||||
|
fieldIdOrName = args[0] as string;
|
||||||
|
tableId = opts.tableId || '';
|
||||||
|
}
|
||||||
|
if (!tableId || !fieldIdOrName)
|
||||||
|
throw new Error('Usage: field get <tableId> <fieldIdOrName> (or --table-id <id> <fieldIdOrName>)');
|
||||||
const field = await getFieldFlexible(ctx.client, tableId, fieldIdOrName);
|
const field = await getFieldFlexible(ctx.client, tableId, fieldIdOrName);
|
||||||
if (ctx.json) printJson(field);
|
if (ctx.json) printJson(field);
|
||||||
else {
|
else {
|
||||||
@@ -469,12 +609,16 @@ function recordCmd(): Command {
|
|||||||
|
|
||||||
cmd
|
cmd
|
||||||
.command('get')
|
.command('get')
|
||||||
.description('Query records with filters, pagination, and search')
|
.description("Query records with filters, pagination, and search (alias: 'list')")
|
||||||
|
.alias('list')
|
||||||
.requiredOption('--table-id <id>', 'Table id')
|
.requiredOption('--table-id <id>', 'Table id')
|
||||||
|
.option('--record-id <id>', 'Get a single record by id (alternative to get-one)')
|
||||||
.option('--filter <json>', 'Filter conditions JSON')
|
.option('--filter <json>', 'Filter conditions JSON')
|
||||||
.option('--projection <json>', 'Field projection JSON array')
|
.option('--projection <json>', 'Field projection JSON array')
|
||||||
.option('--page-size <n>', 'Records per page')
|
.option('--page-size <n>', 'Records per page')
|
||||||
|
.option('--take <n>', 'Alias of --page-size')
|
||||||
.option('--page <n>', 'Page number (1-based)')
|
.option('--page <n>', 'Page number (1-based)')
|
||||||
|
.option('--skip <n>', 'Records to skip (raw offset; overrides --page)')
|
||||||
.option('--search <kw>', 'Search keyword')
|
.option('--search <kw>', 'Search keyword')
|
||||||
.option('--base-id <id>', 'Base id (overrides default)')
|
.option('--base-id <id>', 'Base id (overrides default)')
|
||||||
.action(
|
.action(
|
||||||
@@ -484,10 +628,22 @@ function recordCmd(): Command {
|
|||||||
filter?: string;
|
filter?: string;
|
||||||
projection?: string;
|
projection?: string;
|
||||||
pageSize?: string;
|
pageSize?: string;
|
||||||
|
take?: string;
|
||||||
page?: string;
|
page?: string;
|
||||||
|
skip?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
baseId?: string;
|
baseId?: string;
|
||||||
|
recordId?: string;
|
||||||
};
|
};
|
||||||
|
if (opts.recordId) {
|
||||||
|
const rec = await ctx.client.getRecord(opts.tableId, opts.recordId);
|
||||||
|
if (ctx.json) printJson(rec);
|
||||||
|
else {
|
||||||
|
const r = rec as { id?: string; fields?: Record<string, unknown> };
|
||||||
|
printKv([['id', r.id], ...Object.entries(r.fields || {})]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
// validate baseId is present (even though getRecords only needs tableId,
|
// validate baseId is present (even though getRecords only needs tableId,
|
||||||
// we follow the convention that base-scoped commands require baseId config)
|
// we follow the convention that base-scoped commands require baseId config)
|
||||||
requireBaseId(ctx, opts.baseId);
|
requireBaseId(ctx, opts.baseId);
|
||||||
@@ -498,9 +654,12 @@ function recordCmd(): Command {
|
|||||||
if (opts.search) query.search = opts.search;
|
if (opts.search) query.search = opts.search;
|
||||||
|
|
||||||
const page = opts.page ? parseInt(opts.page, 10) : undefined;
|
const page = opts.page ? parseInt(opts.page, 10) : undefined;
|
||||||
const pageSize = opts.pageSize ? parseInt(opts.pageSize, 10) : undefined;
|
const pageSize = opts.pageSize || opts.take
|
||||||
|
? parseInt((opts.pageSize || opts.take) as string, 10)
|
||||||
|
: undefined;
|
||||||
if (pageSize) query.take = pageSize;
|
if (pageSize) query.take = pageSize;
|
||||||
if (page) {
|
if (opts.skip) query.skip = parseInt(opts.skip, 10);
|
||||||
|
else if (page) {
|
||||||
const take = pageSize || 100;
|
const take = pageSize || 100;
|
||||||
query.skip = (page - 1) * take;
|
query.skip = (page - 1) * take;
|
||||||
if (!pageSize) query.take = take;
|
if (!pageSize) query.take = take;
|
||||||
@@ -543,10 +702,12 @@ function recordCmd(): Command {
|
|||||||
cmd
|
cmd
|
||||||
.command('get-one <recordId>')
|
.command('get-one <recordId>')
|
||||||
.description('Get a single record by id')
|
.description('Get a single record by id')
|
||||||
|
.requiredOption('--table-id <id>', 'Table id (record routes are table-scoped)')
|
||||||
.action(
|
.action(
|
||||||
run(async (ctx, args) => {
|
run(async (ctx, args, o) => {
|
||||||
const recordId = args[0] as string;
|
const recordId = args[0] as string;
|
||||||
const record = await ctx.client.getRecord(recordId);
|
const opts = o as { tableId: string };
|
||||||
|
const record = await ctx.client.getRecord(opts.tableId, recordId);
|
||||||
if (ctx.json) printJson(record);
|
if (ctx.json) printJson(record);
|
||||||
else {
|
else {
|
||||||
const rec = record as { id?: string; fields?: Record<string, unknown> };
|
const rec = record as { id?: string; fields?: Record<string, unknown> };
|
||||||
@@ -565,19 +726,50 @@ function recordCmd(): Command {
|
|||||||
.requiredOption('--table-id <id>', 'Table id')
|
.requiredOption('--table-id <id>', 'Table id')
|
||||||
.option('--fields <json>', 'Fields object JSON, e.g. \'{"Title":"hello"}\'')
|
.option('--fields <json>', 'Fields object JSON, e.g. \'{"Title":"hello"}\'')
|
||||||
.option('--records <json>', 'Records array JSON for bulk, e.g. \'[{"fields":{"Title":"a"}}]\'')
|
.option('--records <json>', 'Records array JSON for bulk, e.g. \'[{"fields":{"Title":"a"}}]\'')
|
||||||
|
.option(
|
||||||
|
'--typecast',
|
||||||
|
'Send typecast=true (accepts human-readable values; attachment fields accept http(s) urls)'
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
'--field-key-type <type>',
|
||||||
|
'Keys in --fields/--records are field ids ("id"), names ("name", default) or dbFieldName'
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
'--order <json>',
|
||||||
|
'Insert position in a manually-sorted view: {"viewId":"viwXXX","anchorId":"recYYY","position":"before|after"}'
|
||||||
|
)
|
||||||
.action(
|
.action(
|
||||||
run(async (ctx, _args, o) => {
|
run(async (ctx, _args, o) => {
|
||||||
const opts = o as { tableId: string; fields?: string; records?: string };
|
const opts = o as {
|
||||||
let body: unknown;
|
tableId: string;
|
||||||
|
fields?: string;
|
||||||
|
records?: string;
|
||||||
|
typecast?: boolean;
|
||||||
|
fieldKeyType?: string;
|
||||||
|
order?: string;
|
||||||
|
};
|
||||||
|
let body: {
|
||||||
|
typecast?: boolean;
|
||||||
|
fieldKeyType?: FieldKeyTypeCli;
|
||||||
|
order?: RecordOrderRo;
|
||||||
|
records: unknown;
|
||||||
|
};
|
||||||
if (opts.records) {
|
if (opts.records) {
|
||||||
body = { records: JSON.parse(opts.records) };
|
body = { records: JSON.parse(opts.records) };
|
||||||
} else if (opts.fields) {
|
} else if (opts.fields) {
|
||||||
body = { fields: JSON.parse(opts.fields) };
|
// The create API only accepts {records:[...]}; wrap the single-record
|
||||||
|
// convenience form so both spellings work.
|
||||||
|
body = { records: [{ fields: JSON.parse(opts.fields) }] };
|
||||||
} else {
|
} else {
|
||||||
throw Object.assign(new Error('Provide --fields or --records'), {
|
throw Object.assign(new Error('Provide --fields or --records'), {
|
||||||
code: 'MISSING_FIELDS',
|
code: 'MISSING_FIELDS',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// typecast/fieldKeyType/order are BODY keys of createRecordsRoSchema (not query params)
|
||||||
|
if (opts.typecast) body.typecast = true;
|
||||||
|
const fieldKeyType = parseFieldKeyType(opts.fieldKeyType);
|
||||||
|
if (fieldKeyType) body.fieldKeyType = fieldKeyType;
|
||||||
|
if (opts.order !== undefined) body.order = parseRecordOrder(opts.order);
|
||||||
const result = await ctx.client.createRecord(opts.tableId, body);
|
const result = await ctx.client.createRecord(opts.tableId, body);
|
||||||
if (ctx.json) printJson(result);
|
if (ctx.json) printJson(result);
|
||||||
else process.stdout.write(`✓ Record(s) created\n`);
|
else process.stdout.write(`✓ Record(s) created\n`);
|
||||||
@@ -592,10 +784,38 @@ function recordCmd(): Command {
|
|||||||
'--records <json>',
|
'--records <json>',
|
||||||
'Records JSON, e.g. \'[{"id":"recX","fields":{"Title":"b"}}]\''
|
'Records JSON, e.g. \'[{"id":"recX","fields":{"Title":"b"}}]\''
|
||||||
)
|
)
|
||||||
|
.option(
|
||||||
|
'--typecast',
|
||||||
|
'Send typecast=true (accepts human-readable values; attachment fields accept http(s) urls)'
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
'--field-key-type <type>',
|
||||||
|
'Keys in --records fields are field ids ("id"), names ("name", default) or dbFieldName'
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
'--order <json>',
|
||||||
|
'Move the updated record(s) before/after an anchor in a manually-sorted view: {"viewId":"viwXXX","anchorId":"recYYY","position":"before|after"}'
|
||||||
|
)
|
||||||
.action(
|
.action(
|
||||||
run(async (ctx, _args, o) => {
|
run(async (ctx, _args, o) => {
|
||||||
const opts = o as { tableId: string; records: string };
|
const opts = o as {
|
||||||
const body = { records: JSON.parse(opts.records) };
|
tableId: string;
|
||||||
|
records: string;
|
||||||
|
typecast?: boolean;
|
||||||
|
fieldKeyType?: string;
|
||||||
|
order?: string;
|
||||||
|
};
|
||||||
|
const body: {
|
||||||
|
typecast?: boolean;
|
||||||
|
fieldKeyType?: FieldKeyTypeCli;
|
||||||
|
order?: RecordOrderRo;
|
||||||
|
records: unknown;
|
||||||
|
} = { records: JSON.parse(opts.records) };
|
||||||
|
// typecast/fieldKeyType/order are BODY keys of updateRecordsRoSchema (not query params)
|
||||||
|
if (opts.typecast) body.typecast = true;
|
||||||
|
const fieldKeyType = parseFieldKeyType(opts.fieldKeyType);
|
||||||
|
if (fieldKeyType) body.fieldKeyType = fieldKeyType;
|
||||||
|
if (opts.order !== undefined) body.order = parseRecordOrder(opts.order);
|
||||||
const result = await ctx.client.updateRecord(opts.tableId, body);
|
const result = await ctx.client.updateRecord(opts.tableId, body);
|
||||||
if (ctx.json) printJson(result);
|
if (ctx.json) printJson(result);
|
||||||
else process.stdout.write(`✓ Records updated\n`);
|
else process.stdout.write(`✓ Records updated\n`);
|
||||||
@@ -603,17 +823,33 @@ function recordCmd(): Command {
|
|||||||
);
|
);
|
||||||
|
|
||||||
cmd
|
cmd
|
||||||
.command('delete')
|
.command('delete [recordIds...]')
|
||||||
.description('Batch delete records by ids')
|
.description('Batch delete records by ids')
|
||||||
.requiredOption('--table-id <id>', 'Table id')
|
.requiredOption('--table-id <id>', 'Table id')
|
||||||
.requiredOption('--record-ids <csv>', 'Record ids (comma-separated)')
|
.option('--record-ids <csv>', 'Record ids (comma-separated; or pass ids as arguments)')
|
||||||
.action(
|
.action(
|
||||||
run(async (ctx, _args, o) => {
|
run(async (ctx, args, o) => {
|
||||||
const opts = o as { tableId: string; recordIds: string };
|
const opts = o as { tableId: string; recordIds?: string };
|
||||||
const ids = opts.recordIds
|
// With variadic args commander appends the options bag to positionals —
|
||||||
|
// flatten and keep only actual id strings.
|
||||||
|
const ids = [
|
||||||
|
...new Set(
|
||||||
|
[
|
||||||
|
...(opts.recordIds
|
||||||
|
? opts.recordIds
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean)
|
||||||
|
: []),
|
||||||
|
...(args as unknown[]).flat().filter((x): x is string => typeof x === 'string'),
|
||||||
|
].flat()
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (!ids.length) {
|
||||||
|
throw Object.assign(new Error('Provide record ids (--record-ids or arguments)'), {
|
||||||
|
code: 'MISSING_RECORD_IDS',
|
||||||
|
});
|
||||||
|
}
|
||||||
const result = await ctx.client.deleteRecords(opts.tableId, ids);
|
const result = await ctx.client.deleteRecords(opts.tableId, ids);
|
||||||
if (ctx.json) printJson(result);
|
if (ctx.json) printJson(result);
|
||||||
else process.stdout.write(`✓ ${ids.length} record(s) deleted\n`);
|
else process.stdout.write(`✓ ${ids.length} record(s) deleted\n`);
|
||||||
@@ -630,11 +866,13 @@ function viewCmd(): Command {
|
|||||||
cmd.description('Manage views in a table');
|
cmd.description('Manage views in a table');
|
||||||
|
|
||||||
cmd
|
cmd
|
||||||
.command('list <tableId>')
|
.command('list [tableId]')
|
||||||
.description('List views in a table')
|
.description('List views in a table')
|
||||||
|
.option('--table-id <id>', 'Table id (alternative to the positional argument)')
|
||||||
.action(
|
.action(
|
||||||
run(async (ctx, args) => {
|
run(async (ctx, args, o) => {
|
||||||
const tableId = args[0] as string;
|
const tableId = ((o as { tableId?: string }).tableId || args[0]) as string;
|
||||||
|
if (!tableId) throw new Error('Pass a tableId (positional or --table-id)');
|
||||||
const list = (await ctx.client.listViews(tableId)) as Array<Record<string, unknown>>;
|
const list = (await ctx.client.listViews(tableId)) as Array<Record<string, unknown>>;
|
||||||
if (ctx.json) printJson(list);
|
if (ctx.json) printJson(list);
|
||||||
else {
|
else {
|
||||||
@@ -661,10 +899,12 @@ function viewCmd(): Command {
|
|||||||
cmd
|
cmd
|
||||||
.command('get <viewId>')
|
.command('get <viewId>')
|
||||||
.description('Get view details')
|
.description('Get view details')
|
||||||
|
.requiredOption('--table-id <id>', 'Table id (view routes are table-scoped)')
|
||||||
.action(
|
.action(
|
||||||
run(async (ctx, args) => {
|
run(async (ctx, args, o) => {
|
||||||
const viewId = args[0] as string;
|
const viewId = args[0] as string;
|
||||||
const view = await ctx.client.getView(viewId);
|
const opts = o as { tableId: string };
|
||||||
|
const view = await ctx.client.getView(opts.tableId, viewId);
|
||||||
if (ctx.json) printJson(view);
|
if (ctx.json) printJson(view);
|
||||||
else {
|
else {
|
||||||
const v = view as Record<string, unknown>;
|
const v = view as Record<string, unknown>;
|
||||||
@@ -698,15 +938,34 @@ function viewCmd(): Command {
|
|||||||
|
|
||||||
cmd
|
cmd
|
||||||
.command('update <viewId>')
|
.command('update <viewId>')
|
||||||
.description('Update a view (rename)')
|
.description('Update a view (name, description, filter, sort, group, column-meta)')
|
||||||
|
.requiredOption('--table-id <id>', 'Table id (view routes are table-scoped)')
|
||||||
.option('--name <name>', 'New view name')
|
.option('--name <name>', 'New view name')
|
||||||
|
.option('--description <d>', 'New description')
|
||||||
|
.option('--filter <json>', 'Filter JSON, e.g. \'{"conjunction":"and","filterSet":[...]}\'')
|
||||||
|
.option('--sort <json>', 'Sort JSON, e.g. \'[{"fieldId":"fldX","order":"asc"}]\'')
|
||||||
|
.option('--group <json>', 'Group JSON, e.g. \'[{"fieldId":"fldX","order":"asc"}]\'')
|
||||||
|
.option('--column-meta <json>', 'Column meta JSON array of {fieldId, columnMeta}')
|
||||||
.action(
|
.action(
|
||||||
run(async (ctx, args, o) => {
|
run(async (ctx, args, o) => {
|
||||||
const viewId = args[0] as string;
|
const viewId = args[0] as string;
|
||||||
const opts = o as { name?: string };
|
const opts = o as {
|
||||||
const body: Record<string, unknown> = {};
|
tableId: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
filter?: string;
|
||||||
|
sort?: string;
|
||||||
|
group?: string;
|
||||||
|
columnMeta?: string;
|
||||||
|
};
|
||||||
|
const body: Parameters<typeof ctx.client.updateView>[2] = {};
|
||||||
if (opts.name) body.name = opts.name;
|
if (opts.name) body.name = opts.name;
|
||||||
const view = await ctx.client.updateView(viewId, body);
|
if (opts.description) body.description = opts.description;
|
||||||
|
if (opts.filter) body.filter = JSON.parse(opts.filter);
|
||||||
|
if (opts.sort) body.sort = JSON.parse(opts.sort);
|
||||||
|
if (opts.group) body.group = JSON.parse(opts.group);
|
||||||
|
if (opts.columnMeta) body.columnMeta = JSON.parse(opts.columnMeta);
|
||||||
|
const view = await ctx.client.updateView(opts.tableId, viewId, body);
|
||||||
if (ctx.json) printJson(view);
|
if (ctx.json) printJson(view);
|
||||||
else process.stdout.write(`✓ View ${viewId} updated\n`);
|
else process.stdout.write(`✓ View ${viewId} updated\n`);
|
||||||
})
|
})
|
||||||
@@ -715,10 +974,12 @@ function viewCmd(): Command {
|
|||||||
cmd
|
cmd
|
||||||
.command('delete <viewId>')
|
.command('delete <viewId>')
|
||||||
.description('Delete a view')
|
.description('Delete a view')
|
||||||
|
.requiredOption('--table-id <id>', 'Table id (view routes are table-scoped)')
|
||||||
.action(
|
.action(
|
||||||
run(async (ctx, args) => {
|
run(async (ctx, args, o) => {
|
||||||
const viewId = args[0] as string;
|
const viewId = args[0] as string;
|
||||||
await ctx.client.deleteView(viewId);
|
const opts = o as { tableId: string };
|
||||||
|
await ctx.client.deleteView(opts.tableId, viewId);
|
||||||
if (ctx.json) printJson({ ok: true, id: viewId });
|
if (ctx.json) printJson({ ok: true, id: viewId });
|
||||||
else process.stdout.write(`✓ View ${viewId} deleted\n`);
|
else process.stdout.write(`✓ View ${viewId} deleted\n`);
|
||||||
})
|
})
|
||||||
@@ -775,12 +1036,13 @@ function presetCmd(): Command {
|
|||||||
);
|
);
|
||||||
|
|
||||||
cmd
|
cmd
|
||||||
.command('list <tableId>')
|
.command('list [tableId]')
|
||||||
.description('List view presets of a table (pass --view-id to scope to one view)')
|
.description('List view presets of a table (pass --view-id to scope to one view)')
|
||||||
|
.option('--table-id <id>', 'Table id (alternative to the positional argument)')
|
||||||
.option('--view-id <id>', 'Only presets bound to this view')
|
.option('--view-id <id>', 'Only presets bound to this view')
|
||||||
.action(
|
.action(
|
||||||
run(async (ctx, args, o) => {
|
run(async (ctx, args, o) => {
|
||||||
const tableId = args[0] as string;
|
const tableId = ((o as { tableId?: string }).tableId || args[0]) as string;
|
||||||
const opts = o as unknown as IPresetOptions;
|
const opts = o as unknown as IPresetOptions;
|
||||||
const list = (await ctx.client.listViewPresets(tableId, opts.viewId)) as Array<
|
const list = (await ctx.client.listViewPresets(tableId, opts.viewId)) as Array<
|
||||||
Record<string, unknown>
|
Record<string, unknown>
|
||||||
|
|||||||
+74
-22
@@ -54,19 +54,36 @@ export class Client {
|
|||||||
getBase(baseId: string) {
|
getBase(baseId: string) {
|
||||||
return this.call<unknown>('GET', `/api/base/${baseId}`);
|
return this.call<unknown>('GET', `/api/base/${baseId}`);
|
||||||
}
|
}
|
||||||
|
createBase(spaceId: string, name: string) {
|
||||||
|
return this.call<unknown>('POST', '/api/base', { spaceId, name });
|
||||||
|
}
|
||||||
|
deleteBase(baseId: string) {
|
||||||
|
return this.call<{ id: string }>('DELETE', `/api/base/${baseId}`);
|
||||||
|
}
|
||||||
|
listSpaces() {
|
||||||
|
return this.call<unknown[]>('GET', '/api/space');
|
||||||
|
}
|
||||||
|
createSpace(name: string) {
|
||||||
|
return this.call<unknown>('POST', '/api/space', { name });
|
||||||
|
}
|
||||||
|
|
||||||
// ---- table ----
|
// ---- table ----
|
||||||
listTables(baseId: string) {
|
listTables(baseId: string) {
|
||||||
return this.call<unknown[]>('GET', `/api/base/${baseId}/table`);
|
return this.call<unknown[]>('GET', `/api/base/${baseId}/table`);
|
||||||
}
|
}
|
||||||
getTable(tableId: string) {
|
getTable(tableId: string, baseId?: string) {
|
||||||
return this.call<unknown>('GET', `/api/table/${tableId}`);
|
// Route is base-nested: GET /api/base/:baseId/table/:tableId (a bare
|
||||||
|
// /api/table/:tableId does not exist and 404s).
|
||||||
|
const base = baseId ?? this.cfg.baseId;
|
||||||
|
return this.call<unknown>('GET', `/api/base/${base}/table/${tableId}`);
|
||||||
}
|
}
|
||||||
createTable(baseId: string, body: unknown) {
|
createTable(baseId: string, body: unknown) {
|
||||||
return this.call<unknown>('POST', `/api/base/${baseId}/table`, body);
|
return this.call<unknown>('POST', `/api/base/${baseId}/table`, body);
|
||||||
}
|
}
|
||||||
updateTable(tableId: string, body: unknown) {
|
updateTable(tableId: string, body: { name: string }, baseId?: string) {
|
||||||
return this.call<unknown>('PUT', `/api/table/${tableId}`, body);
|
// Rename goes through the /name sub-resource (a bare PUT .../table/:id 404s).
|
||||||
|
const base = baseId ?? this.cfg.baseId;
|
||||||
|
return this.call<unknown>('PUT', `/api/base/${base}/table/${tableId}/name`, body);
|
||||||
}
|
}
|
||||||
deleteTable(tableId: string, baseId?: string) {
|
deleteTable(tableId: string, baseId?: string) {
|
||||||
const base = baseId ?? this.cfg.baseId;
|
const base = baseId ?? this.cfg.baseId;
|
||||||
@@ -131,20 +148,29 @@ export class Client {
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
getRecord(recordId: string, query?: Record<string, unknown>) {
|
getRecord(tableId: string, recordId: string, query?: Record<string, unknown>) {
|
||||||
|
// Route is table-scoped: GET /api/table/:tableId/record/:recordId
|
||||||
const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : '';
|
const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : '';
|
||||||
return this.call<unknown>('GET', `/api/record/${recordId}${qs}`);
|
return this.call<unknown>('GET', `/api/table/${tableId}/record/${recordId}${qs}`);
|
||||||
}
|
}
|
||||||
createRecord(tableId: string, body: unknown) {
|
createRecord(tableId: string, body: unknown, query?: Record<string, unknown>) {
|
||||||
return this.call<unknown>('POST', `/api/table/${tableId}/record`, body);
|
const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : '';
|
||||||
|
return this.call<unknown>('POST', `/api/table/${tableId}/record${qs}`, body);
|
||||||
}
|
}
|
||||||
updateRecord(tableId: string, body: unknown) {
|
updateRecord(tableId: string, body: unknown, query?: Record<string, unknown>) {
|
||||||
return this.call<unknown>('PATCH', `/api/table/${tableId}/record`, body);
|
const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : '';
|
||||||
|
return this.call<unknown>('PATCH', `/api/table/${tableId}/record${qs}`, body);
|
||||||
}
|
}
|
||||||
updateRecordOne(recordId: string, body: unknown) {
|
updateRecordOne(tableId: string, recordId: string, body: unknown, query?: Record<string, unknown>) {
|
||||||
return this.call<unknown>('PATCH', `/api/record/${recordId}`, body);
|
const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : '';
|
||||||
|
return this.call<unknown>('PATCH', `/api/table/${tableId}/record/${recordId}${qs}`, body);
|
||||||
}
|
}
|
||||||
deleteRecords(tableId: string, recordIds: string[]) {
|
deleteRecords(tableId: string, recordIds: string[]) {
|
||||||
|
if (recordIds.length === 1) {
|
||||||
|
// Bulk query ?recordIds=x parses as a string and 400s — single deletes
|
||||||
|
// use the per-record route.
|
||||||
|
return this.call<unknown>('DELETE', `/api/table/${tableId}/record/${recordIds[0]}`);
|
||||||
|
}
|
||||||
const qs = recordIds.map((id) => `recordIds=${encodeURIComponent(id)}`).join('&');
|
const qs = recordIds.map((id) => `recordIds=${encodeURIComponent(id)}`).join('&');
|
||||||
return this.call<unknown>('DELETE', `/api/table/${tableId}/record?${qs}`);
|
return this.call<unknown>('DELETE', `/api/table/${tableId}/record?${qs}`);
|
||||||
}
|
}
|
||||||
@@ -153,17 +179,47 @@ export class Client {
|
|||||||
listViews(tableId: string) {
|
listViews(tableId: string) {
|
||||||
return this.call<unknown[]>('GET', `/api/table/${tableId}/view`);
|
return this.call<unknown[]>('GET', `/api/table/${tableId}/view`);
|
||||||
}
|
}
|
||||||
getView(viewId: string) {
|
getView(tableId: string, viewId: string) {
|
||||||
return this.call<unknown>('GET', `/api/view/${viewId}`);
|
// View routes are table-scoped: /api/view/:viewId alone does not exist.
|
||||||
|
return this.call<unknown>('GET', `/api/table/${tableId}/view/${viewId}`);
|
||||||
}
|
}
|
||||||
createView(tableId: string, body: unknown) {
|
createView(tableId: string, body: unknown) {
|
||||||
return this.call<unknown>('POST', `/api/table/${tableId}/view`, body);
|
return this.call<unknown>('POST', `/api/table/${tableId}/view`, body);
|
||||||
}
|
}
|
||||||
updateView(viewId: string, body: unknown) {
|
/** Update view parts — each key goes to its own sub-resource route. */
|
||||||
return this.call<unknown>('PATCH', `/api/view/${viewId}`, body);
|
async updateView(
|
||||||
|
tableId: string,
|
||||||
|
viewId: string,
|
||||||
|
parts: {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
filter?: unknown;
|
||||||
|
sort?: unknown;
|
||||||
|
group?: unknown;
|
||||||
|
columnMeta?: unknown;
|
||||||
|
options?: unknown;
|
||||||
}
|
}
|
||||||
deleteView(viewId: string) {
|
): Promise<unknown> {
|
||||||
return this.call<{ id: string }>('DELETE', `/api/view/${viewId}`);
|
const base = `/api/table/${tableId}/view/${viewId}`;
|
||||||
|
let last: unknown;
|
||||||
|
if (parts.name !== undefined) last = await this.call('PUT', `${base}/name`, { name: parts.name });
|
||||||
|
if (parts.description !== undefined)
|
||||||
|
last = await this.call('PUT', `${base}/description`, { description: parts.description });
|
||||||
|
if (parts.filter !== undefined) last = await this.call('PUT', `${base}/filter`, { filter: parts.filter });
|
||||||
|
if (parts.sort !== undefined) {
|
||||||
|
// The API wants {sortObjs:[...]}; accept the natural bare-array form too.
|
||||||
|
const sort = Array.isArray(parts.sort) ? { sortObjs: parts.sort } : parts.sort;
|
||||||
|
last = await this.call('PUT', `${base}/sort`, { sort });
|
||||||
|
}
|
||||||
|
if (parts.group !== undefined) last = await this.call('PUT', `${base}/group`, { group: parts.group });
|
||||||
|
if (parts.columnMeta !== undefined)
|
||||||
|
last = await this.call('PUT', `${base}/column-meta`, parts.columnMeta);
|
||||||
|
if (parts.options !== undefined)
|
||||||
|
last = await this.call('PATCH', `${base}/options`, { options: parts.options });
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
deleteView(tableId: string, viewId: string) {
|
||||||
|
return this.call<{ id: string }>('DELETE', `/api/table/${tableId}/view/${viewId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- view preset (self-built contract) ----
|
// ---- view preset (self-built contract) ----
|
||||||
@@ -297,10 +353,6 @@ export class Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- SQL (read-only) ----
|
// ---- SQL (read-only) ----
|
||||||
sql(baseId: string, sql: string) {
|
|
||||||
return this.call<unknown[]>('POST', `/api/base/${baseId}/sql`, { sql });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- plugin ----
|
// ---- plugin ----
|
||||||
getPlugins() {
|
getPlugins() {
|
||||||
return this.call<unknown[]>('GET', '/api/plugin');
|
return this.call<unknown[]>('GET', '/api/plugin');
|
||||||
|
|||||||
+33
-1
@@ -7,6 +7,36 @@ import { resolveConfig } from './config';
|
|||||||
import { Client, ApiError } from './client';
|
import { Client, ApiError } from './client';
|
||||||
import { describeError } from './request';
|
import { describeError } from './request';
|
||||||
|
|
||||||
|
/** Actionable hints for recurring backend error shapes — the goal is that an
|
||||||
|
* agent (or human) reading the error knows the next command to try, instead
|
||||||
|
* of spending a round-trip re-deriving the cause. */
|
||||||
|
export function buildHint(status: number, message: string): string | undefined {
|
||||||
|
const m = message || '';
|
||||||
|
if (/Invalid tableId/i.test(m))
|
||||||
|
return '表不存在或不属于当前 base —— 用 `inkreach-teable table list` 核对 ID;确认 --base-id / INKREACH_TEABLE_BASE_ID 指向正确的基地。';
|
||||||
|
if (/Workflow not found/i.test(m))
|
||||||
|
return '工作流不存在(或已被软删除)—— 用 `inkreach-teable workflow list` 核对 ID;workflow delete 是软删除,列表里可能已不可见。';
|
||||||
|
if (/Cannot (GET|POST|PUT|PATCH|DELETE)/i.test(m))
|
||||||
|
return '路由不存在 —— CLI 与后端版本可能不匹配,升级 CLI:`npm i -g @inkreach/inkreach-teable`;仍失败则用 --debug 反馈。';
|
||||||
|
if (status === 401)
|
||||||
|
return '鉴权失败 —— `inkreach-teable auth status` 检查 token;必要时到 Web UI Settings → Personal Access Tokens 重新签发并 `auth login`。';
|
||||||
|
if (status === 403 || /restricted_resource|Forbidden/i.test(m))
|
||||||
|
return '权限不足 —— 该操作需要更高角色(base owner/creator)或 PAT 授权范围不含此 base;插件上传还需 plugin_developer 白名单。';
|
||||||
|
if (/Invalid value for field|Invalid option/i.test(m))
|
||||||
|
return '值不在字段合法选项内 —— `inkreach-teable field list <tableId> --with-options` 查看可选值;新增选项用 `field update <tableId> <field> --options`。';
|
||||||
|
if (/does not meet the current row conditions/i.test(m))
|
||||||
|
return '违反了字段的 visibilityFilter(行级条件)—— `inkreach-teable field visible-choices <tableId> <recordId> <field>` 查看该行可见选项。';
|
||||||
|
if (/expected array, received/i.test(m))
|
||||||
|
return '参数形状不符 —— 批量接口要数组;单条可改用 --fields,批量用 --records。';
|
||||||
|
if (status === 429)
|
||||||
|
return '触发限流 —— 稍等后重试;批量操作请降低并发或分批执行。';
|
||||||
|
if (/timeout|ETIMEDOUT|ECONNRESET/i.test(m))
|
||||||
|
return '请求超时/连接中断 —— 先用只读命令(如 table list)确认服务可达,再重试原操作。';
|
||||||
|
if (/VALIDATION|Validation error/i.test(m) && status === 400)
|
||||||
|
return '请求校验失败 —— 用 --json 查看完整错误;对照 `<cmd> --help` 的参数示例。';
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Ctx {
|
export interface Ctx {
|
||||||
client: Client;
|
client: Client;
|
||||||
json: boolean;
|
json: boolean;
|
||||||
@@ -62,10 +92,12 @@ export function run<TArgs extends unknown[] = unknown[]>(
|
|||||||
await fn(ctx, positional, options);
|
await fn(ctx, positional, options);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const { status, code, message } = describeError(err);
|
const { status, code, message } = describeError(err);
|
||||||
|
const hint = status ? buildHint(status, message) : undefined;
|
||||||
if (ctx.json) {
|
if (ctx.json) {
|
||||||
process.stdout.write(JSON.stringify({ error: { status, code, message } }) + '\n');
|
process.stdout.write(JSON.stringify({ error: { status, code, message, ...(hint ? { hint } : {}) } }) + '\n');
|
||||||
} else {
|
} else {
|
||||||
process.stderr.write(`✗ ${code}${status ? ` (${status})` : ''}: ${message}\n`);
|
process.stderr.write(`✗ ${code}${status ? ` (${status})` : ''}: ${message}\n`);
|
||||||
|
if (hint) process.stderr.write(` hint: ${hint}\n`);
|
||||||
}
|
}
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user