mirror of
https://github.com/openclaw/lobster.git
synced 2026-08-14 00:48:09 +00:00
feat: add template command
This commit is contained in:
@@ -6,6 +6,7 @@ import { tableCommand } from "./stdlib/table.js";
|
||||
import { whereCommand } from "./stdlib/where.js";
|
||||
import { sortCommand } from "./stdlib/sort.js";
|
||||
import { dedupeCommand } from "./stdlib/dedupe.js";
|
||||
import { templateCommand } from "./stdlib/template.js";
|
||||
import { approveCommand } from "./stdlib/approve.js";
|
||||
import { clawdInvokeCommand } from "./stdlib/clawd_invoke.js";
|
||||
import { stateGetCommand, stateSetCommand } from "./stdlib/state.js";
|
||||
@@ -29,6 +30,7 @@ export function createDefaultRegistry() {
|
||||
whereCommand,
|
||||
sortCommand,
|
||||
dedupeCommand,
|
||||
templateCommand,
|
||||
approveCommand,
|
||||
clawdInvokeCommand,
|
||||
stateGetCommand,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
function getByPath(obj: any, path: string): any {
|
||||
if (path === '.' || path === 'this') return obj;
|
||||
const parts = path.split('.').filter(Boolean);
|
||||
let cur: any = obj;
|
||||
for (const p of parts) {
|
||||
if (cur == null) return undefined;
|
||||
cur = cur[p];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
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);
|
||||
if (val === undefined || val === null) return '';
|
||||
if (typeof val === 'string') return val;
|
||||
return JSON.stringify(val);
|
||||
});
|
||||
}
|
||||
|
||||
export const templateCommand = {
|
||||
name: 'template',
|
||||
meta: {
|
||||
description: 'Render a simple {{path}} template against each input item',
|
||||
argsSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
text: { type: 'string', description: 'Template text (supports {{path}}; {{.}} for the whole item)' },
|
||||
file: { type: 'string', description: 'Template file path' },
|
||||
_: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
sideEffects: [],
|
||||
},
|
||||
help() {
|
||||
return (
|
||||
`template — render a simple template against each item\n\n` +
|
||||
`Usage:\n` +
|
||||
` ... | template --text 'PR {{number}}: {{title}}'\n` +
|
||||
` ... | template --file ./draft.txt\n\n` +
|
||||
`Template syntax:\n` +
|
||||
` - {{field}} or {{nested.field}}\n` +
|
||||
` - {{.}} for the whole item\n` +
|
||||
` - Missing values render as empty string\n`
|
||||
);
|
||||
},
|
||||
async run({ input, args }: any) {
|
||||
let tpl = typeof args.text === 'string' ? args.text : undefined;
|
||||
const file = typeof args.file === 'string' ? args.file : undefined;
|
||||
|
||||
if (!tpl && file) {
|
||||
tpl = await fs.readFile(file, 'utf8');
|
||||
}
|
||||
|
||||
if (!tpl) {
|
||||
const positional = Array.isArray(args._) ? args._ : [];
|
||||
if (positional.length) tpl = positional.join(' ');
|
||||
}
|
||||
|
||||
if (!tpl) throw new Error('template requires --text or --file (or positional text)');
|
||||
|
||||
return {
|
||||
output: (async function* () {
|
||||
for await (const item of input) {
|
||||
yield renderTemplate(String(tpl), item);
|
||||
}
|
||||
})(),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { runPipeline } from '../src/runtime.js';
|
||||
import { createDefaultRegistry } from '../src/commands/registry.js';
|
||||
import { parsePipeline } from '../src/parser.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('template renders fields and nested fields', async () => {
|
||||
const out = await run("template --text 'hi {{user.name}}'", [{ user: { name: 'v' } }]);
|
||||
assert.deepEqual(out, ['hi v']);
|
||||
});
|
||||
|
||||
test('template renders missing fields as empty', async () => {
|
||||
const out = await run("template --text 'x={{nope}}'", [{ a: 1 }]);
|
||||
assert.deepEqual(out, ['x=']);
|
||||
});
|
||||
|
||||
test('template supports {{.}} for whole item', async () => {
|
||||
const out = await run("template --text '{{.}}'", [{ a: 1 }]);
|
||||
assert.deepEqual(out, [JSON.stringify({ a: 1 })]);
|
||||
});
|
||||
|
||||
test('template supports --file', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'lobster-template-'));
|
||||
const file = path.join(dir, 'tpl.txt');
|
||||
await fs.writeFile(file, 'hey {{x}}', 'utf8');
|
||||
const out = await run(`template --file ${file}`, [{ x: 'ok' }]);
|
||||
assert.deepEqual(out, ['hey ok']);
|
||||
});
|
||||
Reference in New Issue
Block a user