mirror of
https://github.com/openclaw/lobster.git
synced 2026-08-14 08:52:48 +00:00
feat: add template filters for template command
This commit is contained in:
@@ -11,6 +11,7 @@ All notable changes to Lobster will be documented in this file.
|
||||
- Add workflow-level LLM cost tracking with `_meta.cost` summaries, per-step usage attribution, and optional `cost_limit` controls with `warn`/`stop` actions (plus custom pricing via `LOBSTER_LLM_PRICING_JSON`). Thanks to [@scottgl9](https://github.com/scottgl9) (PR [#70](https://github.com/openclaw/lobster/pull/70)).
|
||||
- Add `parallel` workflow steps with branch fan-out, `wait: all|any`, block-level timeout support, and branch result references in downstream steps. Thanks to [@scottgl9](https://github.com/scottgl9) (PR [#69](https://github.com/openclaw/lobster/pull/69)).
|
||||
- Add `for_each` workflow steps for per-item sub-step execution over arrays, including loop-scoped vars (`item_var`/`index_var`), optional `batch_size` + `pause_ms`, and collected iteration outputs for downstream steps. Thanks to [@scottgl9](https://github.com/scottgl9) (PR [#68](https://github.com/openclaw/lobster/pull/68)).
|
||||
- Add pipe-based template filters (for example `upper`, `length`, `join`, `default`, `date`) for the `template` command with quote-aware filter parsing and chain evaluation. Thanks to [@scottgl9](https://github.com/scottgl9) (PR [#67](https://github.com/openclaw/lobster/pull/67)).
|
||||
|
||||
## 2026.4.6
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import { applyFilters } from '../../core/filters.js';
|
||||
|
||||
function getByPath(obj: any, path: string): any {
|
||||
if (path === '.' || path === 'this') return obj;
|
||||
@@ -11,12 +12,54 @@ function getByPath(obj: any, path: string): any {
|
||||
return cur;
|
||||
}
|
||||
|
||||
function splitFilterChain(expr: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let current = '';
|
||||
let i = 0;
|
||||
while (i < expr.length) {
|
||||
const ch = expr[i];
|
||||
if (ch === '"' || ch === "'") {
|
||||
const quote = ch;
|
||||
current += ch;
|
||||
i += 1;
|
||||
while (i < expr.length && expr[i] !== quote) {
|
||||
if (expr[i] === '\\' && i + 1 < expr.length) {
|
||||
current += expr[i] + expr[i + 1];
|
||||
i += 2;
|
||||
} else {
|
||||
current += expr[i];
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
if (i < expr.length) {
|
||||
current += expr[i];
|
||||
i += 1;
|
||||
}
|
||||
} else if (ch === '|') {
|
||||
parts.push(current.trim());
|
||||
current = '';
|
||||
i += 1;
|
||||
} else {
|
||||
current += ch;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
parts.push(current.trim());
|
||||
return parts;
|
||||
}
|
||||
|
||||
function renderTemplate(tpl: string, ctx: any): string {
|
||||
return tpl.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_m, expr) => {
|
||||
const key = String(expr ?? '').trim();
|
||||
const val = getByPath(ctx, key);
|
||||
const rawExpr = String(expr ?? '').trim();
|
||||
const parts = splitFilterChain(rawExpr);
|
||||
const key = parts[0];
|
||||
let val: unknown = getByPath(ctx, key);
|
||||
if (parts.length > 1) {
|
||||
val = applyFilters(val, parts.slice(1));
|
||||
}
|
||||
if (val === undefined || val === null) return '';
|
||||
if (typeof val === 'string') return val;
|
||||
if (typeof val === 'number' || typeof val === 'boolean') return String(val);
|
||||
return JSON.stringify(val);
|
||||
});
|
||||
}
|
||||
@@ -28,7 +71,7 @@ export const templateCommand = {
|
||||
argsSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
text: { type: 'string', description: 'Template text (supports {{path}}; {{.}} for the whole item)' },
|
||||
text: { type: 'string', description: 'Template text (supports {{path}}, {{path | filter}}, {{.}} for the whole item)' },
|
||||
file: { type: 'string', description: 'Template file path' },
|
||||
_: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
@@ -45,7 +88,12 @@ export const templateCommand = {
|
||||
`Template syntax:\n` +
|
||||
` - {{field}} or {{nested.field}}\n` +
|
||||
` - {{.}} for the whole item\n` +
|
||||
` - Missing values render as empty string\n`
|
||||
` - {{field | filter}} with pipe-based filters\n` +
|
||||
` - Missing values render as empty string\n\n` +
|
||||
`Filters:\n` +
|
||||
` upper, lower, trim, truncate N, replace "from" "to", split sep\n` +
|
||||
` first, last, length, join sep\n` +
|
||||
` json, string, default val, round N, date fmt\n`
|
||||
);
|
||||
},
|
||||
async run({ input, args }: any) {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
export type FilterFn = (value: unknown, ...args: string[]) => unknown;
|
||||
|
||||
const FILTERS = new Map<string, FilterFn>();
|
||||
|
||||
FILTERS.set('upper', (v) => String(v ?? '').toUpperCase());
|
||||
FILTERS.set('lower', (v) => String(v ?? '').toLowerCase());
|
||||
FILTERS.set('trim', (v) => String(v ?? '').trim());
|
||||
FILTERS.set('truncate', (v, n) => {
|
||||
const s = String(v ?? '');
|
||||
const parsed = parseInt(n ?? '', 10);
|
||||
const len = Number.isNaN(parsed) ? 80 : parsed;
|
||||
return s.length > len ? `${s.slice(0, len)}...` : s;
|
||||
});
|
||||
FILTERS.set('replace', (v, from, to) => String(v ?? '').replaceAll(from ?? '', to ?? ''));
|
||||
FILTERS.set('split', (v, sep) => String(v ?? '').split(sep ?? ','));
|
||||
FILTERS.set('first', (v) => (Array.isArray(v) ? v[0] : v));
|
||||
FILTERS.set('last', (v) => (Array.isArray(v) ? v[v.length - 1] : v));
|
||||
FILTERS.set('length', (v) => {
|
||||
if (Array.isArray(v)) return v.length;
|
||||
if (typeof v === 'string') return v.length;
|
||||
return 0;
|
||||
});
|
||||
FILTERS.set('join', (v, sep) => (Array.isArray(v) ? v.join(sep ?? ', ') : String(v ?? '')));
|
||||
FILTERS.set('json', (v) => JSON.stringify(v, null, 2));
|
||||
FILTERS.set('string', (v) => String(v ?? ''));
|
||||
FILTERS.set('default', (v, def) => (v == null || v === '' ? def : v));
|
||||
FILTERS.set('round', (v, n) => {
|
||||
const num = Number(v);
|
||||
const dec = parseInt(n ?? '', 10) || 0;
|
||||
return Number.isNaN(num) ? v : Number(num.toFixed(dec));
|
||||
});
|
||||
FILTERS.set('date', (v, fmt) => {
|
||||
const d = typeof v === 'number' || (typeof v === 'string' && /^\d+$/.test(v.trim()))
|
||||
? new Date(Number(v))
|
||||
: new Date(String(v));
|
||||
if (Number.isNaN(d.getTime())) return String(v);
|
||||
if (!fmt) return d.toISOString();
|
||||
return fmt
|
||||
.replace('YYYY', String(d.getUTCFullYear()))
|
||||
.replace('MM', String(d.getUTCMonth() + 1).padStart(2, '0'))
|
||||
.replace('DD', String(d.getUTCDate()).padStart(2, '0'))
|
||||
.replace('HH', String(d.getUTCHours()).padStart(2, '0'))
|
||||
.replace('mm', String(d.getUTCMinutes()).padStart(2, '0'))
|
||||
.replace('ss', String(d.getUTCSeconds()).padStart(2, '0'));
|
||||
});
|
||||
|
||||
export function getFilter(name: string): FilterFn | undefined {
|
||||
return FILTERS.get(name);
|
||||
}
|
||||
|
||||
export function parseFilterExpression(expr: string): [string, ...string[]] {
|
||||
const trimmed = expr.trim();
|
||||
const parts: string[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < trimmed.length) {
|
||||
while (i < trimmed.length && trimmed[i] === ' ') i += 1;
|
||||
if (i >= trimmed.length) break;
|
||||
|
||||
if (trimmed[i] === '"' || trimmed[i] === "'") {
|
||||
const quote = trimmed[i];
|
||||
i += 1;
|
||||
let arg = '';
|
||||
while (i < trimmed.length && trimmed[i] !== quote) {
|
||||
if (trimmed[i] === '\\' && i + 1 < trimmed.length) {
|
||||
arg += trimmed[i + 1];
|
||||
i += 2;
|
||||
} else {
|
||||
arg += trimmed[i];
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
if (i < trimmed.length) i += 1;
|
||||
parts.push(arg);
|
||||
} else {
|
||||
let arg = '';
|
||||
while (i < trimmed.length && trimmed[i] !== ' ') {
|
||||
arg += trimmed[i];
|
||||
i += 1;
|
||||
}
|
||||
parts.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return [trimmed];
|
||||
}
|
||||
return parts as [string, ...string[]];
|
||||
}
|
||||
|
||||
export function applyFilters(value: unknown, filterChain: string[]): unknown {
|
||||
let result = value;
|
||||
for (const filterExpr of filterChain) {
|
||||
const [name, ...args] = parseFilterExpression(filterExpr);
|
||||
const fn = FILTERS.get(name);
|
||||
if (!fn) throw new Error(`Unknown template filter: ${name}`);
|
||||
result = fn(result, ...args);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { parsePipeline } from '../src/parser.js';
|
||||
import { runPipeline } from '../src/runtime.js';
|
||||
import { createDefaultRegistry } from '../src/commands/registry.js';
|
||||
import { applyFilters, parseFilterExpression } from '../src/core/filters.js';
|
||||
|
||||
async function run(pipelineText: string, input: any[]) {
|
||||
const pipeline = parsePipeline(pipelineText);
|
||||
const registry = createDefaultRegistry();
|
||||
const res = await runPipeline({
|
||||
pipeline,
|
||||
registry,
|
||||
stdin: process.stdin,
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
env: process.env,
|
||||
mode: 'tool',
|
||||
input: (async function* () { for (const x of input) yield x; })(),
|
||||
});
|
||||
return res.items;
|
||||
}
|
||||
|
||||
test('parseFilterExpression parses simple filter', () => {
|
||||
assert.deepEqual(parseFilterExpression('upper'), ['upper']);
|
||||
});
|
||||
|
||||
test('parseFilterExpression parses filter args', () => {
|
||||
assert.deepEqual(parseFilterExpression('truncate 80'), ['truncate', '80']);
|
||||
});
|
||||
|
||||
test('parseFilterExpression parses quoted args', () => {
|
||||
assert.deepEqual(parseFilterExpression('replace "-" "_"'), ['replace', '-', '_']);
|
||||
});
|
||||
|
||||
test('applyFilters upper', () => {
|
||||
assert.equal(applyFilters('hello', ['upper']), 'HELLO');
|
||||
});
|
||||
|
||||
test('applyFilters lower', () => {
|
||||
assert.equal(applyFilters('HELLO', ['lower']), 'hello');
|
||||
});
|
||||
|
||||
test('applyFilters trim', () => {
|
||||
assert.equal(applyFilters(' hi ', ['trim']), 'hi');
|
||||
});
|
||||
|
||||
test('applyFilters truncate', () => {
|
||||
assert.equal(applyFilters('hello world', ['truncate 5']), 'hello...');
|
||||
});
|
||||
|
||||
test('applyFilters replace', () => {
|
||||
assert.equal(applyFilters('a-b-c', ['replace "-" "_"']), 'a_b_c');
|
||||
});
|
||||
|
||||
test('applyFilters split', () => {
|
||||
assert.deepEqual(applyFilters('a,b,c', ['split ","']), ['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
test('applyFilters first/last', () => {
|
||||
assert.equal(applyFilters([1, 2, 3], ['first']), 1);
|
||||
assert.equal(applyFilters([1, 2, 3], ['last']), 3);
|
||||
});
|
||||
|
||||
test('applyFilters length on array and string', () => {
|
||||
assert.equal(applyFilters([1, 2, 3], ['length']), 3);
|
||||
assert.equal(applyFilters('hello', ['length']), 5);
|
||||
});
|
||||
|
||||
test('applyFilters join', () => {
|
||||
assert.equal(applyFilters(['a', 'b', 'c'], ['join ", "']), 'a, b, c');
|
||||
});
|
||||
|
||||
test('applyFilters json/default/round', () => {
|
||||
assert.equal(applyFilters({ a: 1 }, ['json']), JSON.stringify({ a: 1 }, null, 2));
|
||||
assert.equal(applyFilters(null, ['default "N/A"']), 'N/A');
|
||||
assert.equal(applyFilters('ok', ['default "N/A"']), 'ok');
|
||||
assert.equal(applyFilters(3.14159, ['round 2']), 3.14);
|
||||
});
|
||||
|
||||
test('applyFilters chain', () => {
|
||||
assert.equal(applyFilters(' Hello World ', ['trim', 'upper']), 'HELLO WORLD');
|
||||
});
|
||||
|
||||
test('applyFilters date formatting is UTC-stable', () => {
|
||||
const result = applyFilters(1710000000000, ['date "YYYY-MM-DD"']);
|
||||
assert.equal(result, '2024-03-09');
|
||||
});
|
||||
|
||||
test('applyFilters unknown filter throws', () => {
|
||||
assert.throws(() => applyFilters('x', ['nonexistent']), /Unknown template filter/);
|
||||
});
|
||||
|
||||
test('template filter integration: upper', async () => {
|
||||
const out = await run("template --text '{{name | upper}}'", [{ name: 'alice' }]);
|
||||
assert.deepEqual(out, ['ALICE']);
|
||||
});
|
||||
|
||||
test('template filter integration: length', async () => {
|
||||
const out = await run("template --text '{{items | length}}'", [{ items: [1, 2, 3] }]);
|
||||
assert.deepEqual(out, ['3']);
|
||||
});
|
||||
|
||||
test('template filter integration: default', async () => {
|
||||
const out = await run("template --text '{{missing | default \"N/A\"}}'", [{ other: 1 }]);
|
||||
assert.deepEqual(out, ['N/A']);
|
||||
});
|
||||
|
||||
test('template filter integration: chained', async () => {
|
||||
const out = await run("template --text '{{name | trim | upper}}'", [{ name: ' bob ' }]);
|
||||
assert.deepEqual(out, ['BOB']);
|
||||
});
|
||||
|
||||
test('template integration without filters remains unchanged', async () => {
|
||||
const out = await run("template --text 'hi {{name}}'", [{ name: 'v' }]);
|
||||
assert.deepEqual(out, ['hi v']);
|
||||
});
|
||||
|
||||
test('template filter integration: join', async () => {
|
||||
const out = await run("template --text '{{tags | join \", \"}}'", [{ tags: ['a', 'b', 'c'] }]);
|
||||
assert.deepEqual(out, ['a, b, c']);
|
||||
});
|
||||
|
||||
test('template filter splitter handles quoted pipe characters', async () => {
|
||||
const out = await run("template --text '{{line | split \"|\" | first}}'", [{ line: 'a|b|c' }]);
|
||||
assert.deepEqual(out, ['a']);
|
||||
});
|
||||
Reference in New Issue
Block a user