feat: add template command

This commit is contained in:
Vignesh Natarajan
2026-01-23 16:31:40 -08:00
parent 5921d6e7d9
commit 5bcd20c70d
3 changed files with 124 additions and 0 deletions
+2
View File
@@ -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,
+74
View File
@@ -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);
}
})(),
};
},
};
+48
View File
@@ -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']);
});