From 6cbc089fd80f491d778d970ab5148c28b2b075e8 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Fri, 23 Jan 2026 16:32:46 -0800 Subject: [PATCH] feat: add map command --- src/commands/registry.ts | 2 + src/commands/stdlib/map.ts | 101 +++++++++++++++++++++++++++++++++++++ test/map.test.ts | 43 ++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 src/commands/stdlib/map.ts create mode 100644 test/map.test.ts diff --git a/src/commands/registry.ts b/src/commands/registry.ts index a51f21b..3278ce2 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -7,6 +7,7 @@ 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 { mapCommand } from "./stdlib/map.js"; import { approveCommand } from "./stdlib/approve.js"; import { clawdInvokeCommand } from "./stdlib/clawd_invoke.js"; import { stateGetCommand, stateSetCommand } from "./stdlib/state.js"; @@ -31,6 +32,7 @@ export function createDefaultRegistry() { sortCommand, dedupeCommand, templateCommand, + mapCommand, approveCommand, clawdInvokeCommand, stateGetCommand, diff --git a/src/commands/stdlib/map.ts b/src/commands/stdlib/map.ts new file mode 100644 index 0000000..e47332c --- /dev/null +++ b/src/commands/stdlib/map.ts @@ -0,0 +1,101 @@ +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); + }); +} + +function parseAssignments(tokens: any[]): Array<{ key: string; value: string }> { + const out: Array<{ key: string; value: string }> = []; + for (const tok of tokens ?? []) { + const s = String(tok); + const idx = s.indexOf('='); + if (idx === -1) continue; + const key = s.slice(0, idx).trim(); + const value = s.slice(idx + 1); + if (!key) continue; + out.push({ key, value }); + } + return out; +} + +export const mapCommand = { + name: 'map', + meta: { + description: 'Transform items (wrap/unwrap/add fields)', + argsSchema: { + type: 'object', + properties: { + wrap: { type: 'string', description: 'Wrap each item as {wrap: item}' }, + unwrap: { type: 'string', description: 'Unwrap a field (yield item[unwrap])' }, + _: { type: 'array', items: { type: 'string' }, description: 'Optional assignments like key=value (value supports {{path}})' }, + }, + required: [], + }, + sideEffects: [], + }, + help() { + return ( + `map — transform items\n\n` + + `Usage:\n` + + ` ... | map --wrap item\n` + + ` ... | map --unwrap item\n` + + ` ... | map foo=bar id={{id}}\n\n` + + `Notes:\n` + + ` - Assignments are added to an object item (preserves existing fields).\n` + + ` - Assignment values support template placeholders like {{id}} and {{nested.field}}.\n` + ); + }, + async run({ input, args }: any) { + const wrap = typeof args.wrap === 'string' ? args.wrap : undefined; + const unwrap = typeof args.unwrap === 'string' ? args.unwrap : undefined; + const assignments = parseAssignments(Array.isArray(args._) ? args._ : []); + + if (wrap && unwrap) throw new Error('map cannot use both --wrap and --unwrap'); + + return { + output: (async function* () { + for await (const item of input) { + let cur: any = item; + + if (unwrap) { + if (cur && typeof cur === 'object') cur = cur[unwrap]; + else cur = undefined; + yield cur; + continue; + } + + if (wrap) { + cur = { [wrap]: cur }; + } + + if (assignments.length > 0) { + if (cur === null || typeof cur !== 'object' || Array.isArray(cur)) { + // If current is not an object, turn it into one so we can attach fields. + cur = { value: cur }; + } + for (const { key, value } of assignments) { + cur[key] = renderTemplate(String(value), item); + } + } + + yield cur; + } + })(), + }; + }, +}; diff --git a/test/map.test.ts b/test/map.test.ts new file mode 100644 index 0000000..b83f1b3 --- /dev/null +++ b/test/map.test.ts @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +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('map --wrap wraps items', async () => { + const out = await run('map --wrap item', [1, 2]); + assert.deepEqual(out, [{ item: 1 }, { item: 2 }]); +}); + +test('map --unwrap unwraps fields', async () => { + const out = await run('map --unwrap x', [{ x: 1 }, { x: 2 }]); + assert.deepEqual(out, [1, 2]); +}); + +test('map adds fields via assignments with template values', async () => { + const out = await run('map kind=pr id={{id}}', [{ id: 123, title: 't' }]); + // assignment overwrites existing id with rendered string + assert.deepEqual(out, [{ id: '123', title: 't', kind: 'pr' }]); +}); + +test('map converts non-object items to {value: item} when adding fields', async () => { + const out = await run('map kind=num', [5]); + assert.deepEqual(out, [{ value: 5, kind: 'num' }]); +});