feat: add map command

This commit is contained in:
Vignesh Natarajan
2026-01-23 16:32:46 -08:00
parent 5bcd20c70d
commit 6cbc089fd8
3 changed files with 146 additions and 0 deletions
+2
View File
@@ -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,
+101
View File
@@ -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;
}
})(),
};
},
};
+43
View File
@@ -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' }]);
});