mirror of
https://github.com/openclaw/lobster.git
synced 2026-08-14 08:52:48 +00:00
feat: add map command
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
})(),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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' }]);
|
||||
});
|
||||
Reference in New Issue
Block a user