#!/usr/bin/env node /** * subtraction-write CLI — execute an APPROVED subtraction set (§C6, chunk #63). * * The one path in the plugin that removes configuration. It takes no judgement * of its own: it is handed a set of blocks a human approved, and its whole job * is to refuse anything that no longer matches, is load-bearing, or leaves the * repo without an explicit go-ahead. * * Usage: * node subtraction-write-cli.mjs --approved * [--repo ] * [--approve-scope] [--dry-run] * [--output-file ] [--json] * * The approval file is written by MAIN CONTEXT, not by the lens agent — * `optimize.md` renders the candidates, the operator picks, and the command * materializes the choice. That is where the decision actually happens, and it * keeps this path off the unverified agent write surface * ([[subagent-harness-blocks-report-writes]] lists optimize as open). * * { "sessionId": "...", * "removals": [ { "file": "...", "line": 12, "endLine": 15, "text": "..." } ] } * * Exit codes: 0 = verdict, 3 = the CLI could not do its job (bad argv, * unreadable or malformed approval file). * * A gated or refused removal is NOT exit 3. "This write leaves the repo" and * "that block no longer looks like that" are verdicts about a write, and they * ride in the payload — a command cannot act on something that only ever * reached stderr (#62, F3's class). * * Zero external dependencies. */ import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { writeOutputFile } from './lib/write-output.mjs'; import { requireValidArgs } from './lib/cli-args.mjs'; import { applySubtraction } from './lib/subtraction-write.mjs'; /** Flag surface. Anything else is exit 3. */ const ARG_SPEC = { boolean: ['--json', '--dry-run', '--approve-scope'], value: ['--approved', '--repo', '--output-file'], }; async function main() { const args = process.argv.slice(2); if (!requireValidArgs(args, ARG_SPEC)) return; let approvedPath = null; let repo = process.cwd(); let outputFile = null; let jsonMode = false; let dryRun = false; let approveScope = false; for (let i = 0; i < args.length; i++) { if (args[i] === '--json') jsonMode = true; else if (args[i] === '--dry-run') dryRun = true; else if (args[i] === '--approve-scope') approveScope = true; else if (args[i] === '--approved') approvedPath = args[++i]; else if (args[i] === '--repo') repo = args[++i]; else if (args[i] === '--output-file') outputFile = args[++i]; } if (!approvedPath) { process.stderr.write('Error: --approved is required\n'); process.exitCode = 3; return; } let approval; try { approval = JSON.parse(await readFile(resolve(approvedPath), 'utf-8')); } catch (err) { process.stderr.write(`Error: could not read approval file: ${err.message}\n`); process.exitCode = 3; return; } // Malformed input is a tool error, not a verdict: an empty or wrong-shaped // approval must not read as "nothing to remove, all done". if (!Array.isArray(approval.removals) || approval.removals.length === 0) { process.stderr.write('Error: approval file has no `removals` array\n'); process.exitCode = 3; return; } for (const r of approval.removals) { if (!r || typeof r.file !== 'string' || typeof r.text !== 'string') { process.stderr.write('Error: every removal needs `file`, `line`, `endLine` and `text`\n'); process.exitCode = 3; return; } } const result = await applySubtraction(approval.removals, { repoRoot: repo, dryRun, approveScope }); const payload = { meta: { repo: resolve(repo), sessionId: approval.sessionId || null, approvedCount: approval.removals.length, dryRun, }, gate: result.gate, requiresApproval: result.requiresApproval, disclosures: result.disclosures, targets: result.targets, backupId: result.backupId, filesWritten: result.filesWritten, // The removed text travels back so the caller can show and log exactly what // left the file. The backup is the recovery artifact; this is the receipt. applied: result.applied, refused: result.refused, counts: { applied: result.applied.length, refused: result.refused.length, filesWritten: result.filesWritten.length, }, }; const json = `${JSON.stringify(payload, null, 2)}\n`; if (outputFile) { await writeOutputFile(outputFile, json); // Nothing on stdout when writing to a file (ux-rules rule 1). } else if (jsonMode) { process.stdout.write(json); } else { for (const a of payload.applied) { process.stdout.write(`${payload.meta.dryRun ? 'would-remove' : 'removed'}\t${a.file}:${a.line}-${a.endLine}\n`); } for (const r of payload.refused) { process.stdout.write(`refused:${r.reason}\t${r.file}:${r.line}-${r.endLine}\n`); } } } try { await main(); } catch (err) { process.stderr.write(`Error: ${err.message}\n`); process.exitCode = 3; }