#!/usr/bin/env node /** * campaign-export-cli — export a tracked repo's action plan into that repo's own `docs/` * (v5.7 Fase 2, Block 4c). * * Block 4b built the cross-repo prioritized backlog; this is the "plan export" half of Block 4c. * Given a repo tracked in the campaign ledger, it resolves the repo's linked config-audit * session, reads that session's `action-plan.md`, and assembles (via the pure * `campaign-export` lib) a `docs/config-audit-plan-.md` document carrying a * provenance header + the verbatim plan ("planer følger arbeidsstedet"). * * Read-only by DEFAULT (a dry-run preview that returns the assembled `document` + `targetPath` * so the command can show the user what will be written). The actual write happens ONLY under * the opt-in `--write` flag — which the `/config-audit campaign` command invokes solely after * explicit human approval (Verifiseringsplikt — nothing auto-written). Writing the file * faithfully (a byte-exact copy of the assembled document) is the CLI's job, not the LLM's, so * a 200-line plan is never re-typed and cannot drift. * * Execution is NOT here: Block 4c reuses the existing `/config-audit implement` (backup + * apply + verify) + `/config-audit rollback`. This CLI only exports the durable record. * * Naming: `-cli` suffix → NOT an orchestrated scanner, so the scanner count is unchanged and * the snapshot suite stays byte-stable. * * Usage: * node campaign-export-cli.mjs --repo [--write] * [--ledger-file

] [--sessions-dir

] [--reference-date ] [--output-file

] * * Exit codes: 0 = exportable (preview ready, or written under --write), * 1 = advisory: repo tracked but not exportable yet (no linked session / no plan), * 3 = error (missing --repo, untracked repo, no/corrupt ledger, unreadable plan). */ import { resolve, join, dirname } from 'node:path'; import { homedir } from 'node:os'; import { readFile, writeFile, mkdir } from 'node:fs/promises'; import { loadLedger, validateLedger, defaultLedgerPath, } from './lib/campaign-ledger.mjs'; import { planExportPath, buildPlanExportDocument } from './lib/campaign-export.mjs'; const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; function fail(message) { process.stderr.write(`Error: ${message}\n`); process.exit(3); } /** Default session store: next to the ledger, OUTSIDE the plugin dir. */ function defaultSessionsDir() { return join(homedir(), '.claude', 'config-audit', 'sessions'); } function parseArgs(argv) { const flags = { repo: null, ledgerFile: null, sessionsDir: null, referenceDate: null, outputFile: null, write: false }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === '--repo' && argv[i + 1] !== undefined) flags.repo = argv[++i]; else if (a === '--ledger-file' && argv[i + 1] !== undefined) flags.ledgerFile = argv[++i]; else if (a === '--sessions-dir' && argv[i + 1] !== undefined) flags.sessionsDir = argv[++i]; else if (a === '--reference-date' && argv[i + 1] !== undefined) flags.referenceDate = argv[++i]; else if (a === '--output-file' && argv[i + 1] !== undefined) flags.outputFile = argv[++i]; else if (a === '--write') flags.write = true; else if (a.startsWith('--')) fail(`unknown flag "${a}"`); else fail(`unexpected argument "${a}"`); } return flags; } async function emit(payload, outputFile, exitCode) { const json = JSON.stringify(payload, null, 2); if (outputFile) await writeFile(outputFile, json, 'utf-8'); else process.stdout.write(json + '\n'); process.exit(exitCode); } async function main() { const flags = parseArgs(process.argv.slice(2)); if (!flags.repo) fail('--repo is required'); if (flags.referenceDate && !DATE_RE.test(flags.referenceDate)) fail('--reference-date must be YYYY-MM-DD'); const ledgerPath = resolve(flags.ledgerFile || defaultLedgerPath()); const sessionsDir = resolve(flags.sessionsDir || defaultSessionsDir()); const repoPath = resolve(flags.repo); // The clock is read here ONLY — passed to the pure lib as the injected `now`. const now = flags.referenceDate || new Date().toISOString().slice(0, 10); let ledger; try { ledger = await loadLedger(ledgerPath); } catch (err) { fail(`could not read ledger at ${ledgerPath}: ${err.message}`); } if (ledger === null) fail(`no campaign ledger at ${ledgerPath} — run "/config-audit campaign init" first`); const { valid, errors } = validateLedger(ledger); if (!valid) fail(`ledger at ${ledgerPath} is invalid:\n - ${errors.join('\n - ')}`); const repo = ledger.repos.find((r) => r.path === repoPath); if (!repo) fail(`repo "${repoPath}" is not tracked in the campaign — add it first`); const repoInfo = { path: repo.path, name: repo.name, status: repo.status, sessionId: repo.sessionId ?? null }; // Gate 1: the repo must have a linked session (set via `set-status … --session `). if (typeof repo.sessionId !== 'string' || repo.sessionId.trim() === '') { return emit( { status: 'ok', action: 'export', repo: repoInfo, exportable: false, problems: ['no-session-linked'], written: false, targetPath: null, document: null }, flags.outputFile, 1, ); } // Gate 2: that session must carry an action-plan.md (i.e. `/config-audit plan` has run). const sourcePlanPath = join(sessionsDir, repo.sessionId, 'action-plan.md'); let planMarkdown; try { planMarkdown = await readFile(sourcePlanPath, 'utf-8'); } catch (err) { if (err && err.code === 'ENOENT') { return emit( { status: 'ok', action: 'export', repo: repoInfo, sessionId: repo.sessionId, sourcePlanPath, exportable: false, problems: ['no-action-plan'], written: false, targetPath: null, document: null }, flags.outputFile, 1, ); } fail(`could not read action plan at ${sourcePlanPath}: ${err.message}`); } const targetPath = planExportPath(repo.path, repo.sessionId); const document = buildPlanExportDocument({ repoName: repo.name, repoPath: repo.path, sessionId: repo.sessionId, planMarkdown, now, }); let written = false; if (flags.write) { await mkdir(dirname(targetPath), { recursive: true }); await writeFile(targetPath, document, 'utf-8'); written = true; } return emit( { status: 'ok', action: 'export', repo: repoInfo, sessionId: repo.sessionId, sourcePlanPath, exportable: true, problems: [], written, targetPath, document }, flags.outputFile, 0, ); } const isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname); if (isDirectRun) { main().catch((err) => { process.stderr.write(`Fatal: ${err.message}\n`); process.exit(3); }); }