diff --git a/src/commands/registry.ts b/src/commands/registry.ts index b82e188..a51f21b 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -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, diff --git a/src/commands/stdlib/template.ts b/src/commands/stdlib/template.ts new file mode 100644 index 0000000..f857a95 --- /dev/null +++ b/src/commands/stdlib/template.ts @@ -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); + } + })(), + }; + }, +}; diff --git a/test/template.test.ts b/test/template.test.ts new file mode 100644 index 0000000..03b6c34 --- /dev/null +++ b/test/template.test.ts @@ -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']); +});