feat: add template filters for template command

This commit is contained in:
Vignesh Natarajan
2026-04-11 15:44:57 -07:00
parent 933fb49f6f
commit 60c976571a
4 changed files with 281 additions and 4 deletions
+1
View File
@@ -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
+52 -4
View File
@@ -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) {
+100
View File
@@ -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;
}
+128
View File
@@ -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']);
});