Files
inkreach-teable-cli/src/lib/client.ts
T
yeuimu 5fa843df05 feat: add inkreach CLI + inkreach-teable-ops skill
A self-built CLI and companion agent skill for operating a deployed
inkreach-teable instance — configuring tables, fields, records, views, and
the project's OWN automation engine.

Why not the official 'teable' CLI:
  inkreach-teable's automation is self-built with a different contract:
  - conditions (not filter), fieldIds (not watchFieldIds)
  - recordCondition (not recordMatchesConditions)
  - webhook route /api/webhook/{baseId}/{wfId} (not the /base//workflow/ form)
  - scheduledTime backend honors only timing.type + timing.interval
  The official CLI targets the upstream contract and 404s/misconfigures here.
  Table/field/record/view APIs ARE upstream-compatible, but automation is not.

inkreach CLI (packages/inkreach-cli/):
  - Node.js + commander, zero extra runtime deps
  - Uses node:http (NOT fetch — Node 24 fetch drops Authorization header)
  - Commands: auth (login/status/token), base/table/field/record/view,
    workflow (list/get/create/update/delete/activate/deactivate/test),
    trigger set (8 types, builds correct config keys), action add/update/delete
    (6 types), secret, runs (list/get/rerun), fire (button-click/webhook), sql
  - --json mode for agent parsing; GET->modify->PUT whole-graph for node edits
  - Verified end-to-end: create workflow -> set webhook trigger -> add script
    action -> activate -> fire webhook -> runs list (succeeded)

inkreach-teable-ops skill (.agents/skills/inkreach-teable-ops/):
  - SKILL.md entry: module map, routing rules, 11 key constraints (the real
    contract keys), execution rules, error recovery
  - guides/: cli-reference, automation-guide, tables-fields, instance-access,
    development (build/restart, known gotchas)
  - api-reference/: trigger-types (8 triggers, exact config), action-types
    (6 actions, sandbox globals), field-types (21 types incl. Button)
  - metadata.json + install.sh
2026-08-05 02:04:39 +08:00

273 lines
10 KiB
TypeScript

/**
* High-level API client. Each method maps to one REST endpoint of the
* inkreach-teable backend, using the SELF-BUILT contract (not official teable).
*
* Base/table/field/record/view endpoints are compatible with official teable
* open-api; workflow/automation endpoints use the project's own contract.
*/
import { request, ApiError } from './request';
import { IConfig } from './config';
export class Client {
constructor(private cfg: IConfig) {}
private get headers(): Record<string, string> {
return this.cfg.token ? { Authorization: `Bearer ${this.cfg.token}` } : {};
}
private async call<T>(method: string, p: string, body?: unknown): Promise<T> {
const res = await request<T>(this.cfg.endpoint, p, { method, headers: this.headers, body });
return res.data;
}
// ---- auth / user ----
getMe() {
// GET /api/auth/user — @TokenAccess() enabled, works with PAT.
// (/api/auth/user/me is session-only and 403s for PAT.)
return this.call<unknown>('GET', '/api/auth/user');
}
// ---- access tokens (PAT) ----
listAccessTokens() {
return this.call<unknown[]>('GET', '/api/access-token');
}
createAccessToken(body: {
name: string;
description?: string;
scopes: string[];
baseIds?: string[];
spaceIds?: string[];
hasFullAccess?: boolean;
expiredTime: string;
}) {
return this.call<{ id: string; token: string }>('POST', '/api/access-token', body);
}
deleteAccessToken(id: string) {
return this.call<{ id: string }>('DELETE', `/api/access-token/${id}`);
}
// ---- base ----
listBases() {
// GET /api/base/access/all — lists bases the user can access.
return this.call<unknown[]>('GET', '/api/base/access/all');
}
getBase(baseId: string) {
return this.call<unknown>('GET', `/api/base/${baseId}`);
}
// ---- table ----
listTables(baseId: string) {
return this.call<unknown[]>('GET', `/api/base/${baseId}/table`);
}
getTable(tableId: string) {
return this.call<unknown>('GET', `/api/table/${tableId}`);
}
createTable(baseId: string, body: unknown) {
return this.call<unknown>('POST', `/api/base/${baseId}/table`, body);
}
updateTable(tableId: string, body: unknown) {
return this.call<unknown>('PUT', `/api/table/${tableId}`, body);
}
deleteTable(tableId: string) {
return this.call<{ id: string }>('DELETE', `/api/table/${tableId}`);
}
// ---- field ----
listFields(tableId: string) {
return this.call<unknown[]>('GET', `/api/table/${tableId}/field`);
}
getField(fieldId: string) {
return this.call<unknown>('GET', `/api/field/${fieldId}`);
}
createField(tableId: string, body: unknown) {
return this.call<unknown>('POST', `/api/table/${tableId}/field`, body);
}
updateField(fieldId: string, body: unknown) {
return this.call<unknown>('PUT', `/api/field/${fieldId}`, body);
}
deleteField(fieldId: string) {
return this.call<{ id: string }>('DELETE', `/api/field/${fieldId}`);
}
// ---- record ----
getRecords(tableId: string, query?: Record<string, unknown>) {
const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : '';
return this.call<unknown>('GET', `/api/table/${tableId}/record${qs}`);
}
/** Flatten search[] tuple arrays into repeated query keys (Teable expects search[]=...). */
private flattenQuery(query: Record<string, unknown>): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(query)) {
if (v === undefined || v === null) continue;
if (Array.isArray(v)) {
v.forEach((item) => {
out[`${k}[]`] = typeof item === 'object' ? JSON.stringify(item) : String(item);
});
} else if (typeof v === 'object') {
out[k] = JSON.stringify(v);
} else {
out[k] = String(v);
}
}
return out;
}
getRecord(recordId: string, query?: Record<string, unknown>) {
const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : '';
return this.call<unknown>('GET', `/api/record/${recordId}${qs}`);
}
createRecord(tableId: string, body: unknown) {
return this.call<unknown>('POST', `/api/table/${tableId}/record`, body);
}
updateRecord(tableId: string, body: unknown) {
return this.call<unknown>('PATCH', `/api/table/${tableId}/record`, body);
}
updateRecordOne(recordId: string, body: unknown) {
return this.call<unknown>('PATCH', `/api/record/${recordId}`, body);
}
deleteRecord(tableId: string, body: unknown) {
return this.call<unknown>('DELETE', `/api/table/${tableId}/record`, body);
}
// ---- view ----
listViews(tableId: string) {
return this.call<unknown[]>('GET', `/api/table/${tableId}/view`);
}
getView(viewId: string) {
return this.call<unknown>('GET', `/api/view/${viewId}`);
}
createView(tableId: string, body: unknown) {
return this.call<unknown>('POST', `/api/table/${tableId}/view`, body);
}
updateView(viewId: string, body: unknown) {
return this.call<unknown>('PATCH', `/api/view/${viewId}`, body);
}
deleteView(viewId: string) {
return this.call<{ id: string }>('DELETE', `/api/view/${viewId}`);
}
// ---- workflow (self-built contract) ----
listWorkflows(baseId: string) {
return this.call<unknown[]>('GET', `/api/base/${baseId}/workflow`);
}
getWorkflow(workflowId: string) {
return this.call<IWorkflowVo>('GET', `/api/base/any/workflow/${workflowId}`);
}
/**
* Get workflow by explicit baseId + workflowId. getWorkflow() above hits a
* route that resolves base from the workflow itself, but some auth/permission
* paths need the base-scoped route. Prefer this when baseId is known.
*/
getWorkflowInBase(baseId: string, workflowId: string) {
return this.call<IWorkflowVo>('GET', `/api/base/${baseId}/workflow/${workflowId}`);
}
createWorkflow(baseId: string, body: { name: string; description?: string }) {
return this.call<IWorkflowVo>('POST', `/api/base/${baseId}/workflow`, body);
}
updateWorkflow(baseId: string, workflowId: string, body: { name?: string; description?: string }) {
return this.call<IWorkflowVo>('PATCH', `/api/base/${baseId}/workflow/${workflowId}`, body);
}
deleteWorkflow(baseId: string, workflowId: string) {
return this.call<unknown>('DELETE', `/api/base/${baseId}/workflow/${workflowId}`);
}
activateWorkflow(baseId: string, workflowId: string) {
return this.call<{ snapshotId: string }>('POST', `/api/base/${baseId}/workflow/${workflowId}/activate`);
}
deactivateWorkflow(baseId: string, workflowId: string) {
return this.call<unknown>('POST', `/api/base/${baseId}/workflow/${workflowId}/deactivate`);
}
testWorkflow(baseId: string, workflowId: string) {
return this.call<{ nodeResults: unknown[] }>('POST', `/api/base/${baseId}/workflow/${workflowId}/test`);
}
/** PUT /node — whole-graph replacement (delete-all + create-all in a txn). */
replaceNodes(baseId: string, workflowId: string, body: { nodes: IWorkflowNodeVo[]; edges: IWorkflowEdgeVo[] }) {
return this.call<IWorkflowVo>('PUT', `/api/base/${baseId}/workflow/${workflowId}/node`, body);
}
// ---- secrets ----
listSecrets(baseId: string, workflowId: string) {
return this.call<unknown[]>('GET', `/api/base/${baseId}/workflow/${workflowId}/secret`);
}
createSecret(baseId: string, workflowId: string, body: { name: string; description?: string; value: string }) {
return this.call<unknown>('POST', `/api/base/${baseId}/workflow/${workflowId}/secret`, body);
}
updateSecret(baseId: string, workflowId: string, secretId: string, body: { name?: string; description?: string; value?: string }) {
return this.call<unknown>('PATCH', `/api/base/${baseId}/workflow/${workflowId}/secret/${secretId}`, body);
}
deleteSecret(baseId: string, workflowId: string, secretId: string) {
return this.call<unknown>('DELETE', `/api/base/${baseId}/workflow/${workflowId}/secret/${secretId}`);
}
// ---- execution history ----
listExecutions(baseId: string, workflowId: string) {
return this.call<unknown[]>('GET', `/api/base/${baseId}/workflow/${workflowId}/execution`);
}
getExecution(baseId: string, workflowId: string, executionId: string) {
return this.call<unknown>('GET', `/api/base/${baseId}/workflow/${workflowId}/execution/${executionId}`);
}
rerunExecution(baseId: string, workflowId: string, executionId: string) {
return this.call<unknown>('POST', `/api/base/${baseId}/workflow/${workflowId}/execution/${executionId}/rerun`);
}
// ---- trigger (manual fire for testing) ----
fireButtonClick(tableId: string, recordId: string, fieldId: string) {
return this.call<{ runId: string; tableId: string; fieldId: string; record: unknown }>(
'POST',
`/api/table/${tableId}/record/${recordId}/${fieldId}/button-click`
);
}
fireWebhook(baseId: string, workflowId: string, body?: unknown) {
// NOTE: real backend route is /api/webhook/{baseId}/{workflowId} (no /base/ /workflow/ segments)
return this.call<unknown>('POST', `/api/webhook/${baseId}/${workflowId}`, body);
}
// ---- SQL (read-only) ----
sql(baseId: string, sql: string) {
return this.call<unknown[]>('POST', `/api/base/${baseId}/sql`, { sql });
}
}
// ---- shared types (mirror backend workflow_node / workflow_edge shapes) ----
export type WorkflowNodeType = 'trigger' | 'action' | 'control';
export type WorkflowNodeCategory =
| 'recordCreated' | 'recordUpdated' | 'recordCondition' | 'recordCreatedOrUpdated'
| 'formSubmitted' | 'scheduledTime' | 'buttonClick' | 'webhook'
| 'script' | 'sendEmail' | 'httpRequest'
| 'createRecord' | 'updateRecord' | 'getRecords'
| 'triggerEnd';
export interface IWorkflowNodeVo {
id: string;
workflowId?: string;
type: WorkflowNodeType;
category: WorkflowNodeCategory | string;
config: Record<string, unknown> | null;
name?: string | null;
testResult?: Record<string, unknown> | null;
inputVariables?: unknown;
outputVariables?: unknown;
}
export interface IWorkflowEdgeVo {
id: string;
workflowId?: string;
sourceNodeId: string;
targetNodeId: string;
}
export interface IWorkflowVo {
id: string;
baseId: string;
name: string;
description?: string | null;
isActive: boolean;
hasDraft: boolean;
nodes: IWorkflowNodeVo[];
edges: IWorkflowEdgeVo[];
createdBy?: string;
createdTime?: string;
lastModifiedBy?: string | null;
lastModifiedTime?: string | null;
}
export { ApiError };