scripts/new_adr.js
scripts/new_adr.jsBrowse 11 files
3,338 tokens
12,203 bytes
Token encoding: o200k_base
Snapshot 8b92ba9
← Back to SKILL.md
1#!/usr/bin/env node2/**3 * Create a new ADR markdown file using repo conventions and a template.4 *5 * Design goals:6 * - Safe defaults (auto-detect adr directory + numbering)7 * - No external deps8 * - Works even if the repo has no ADRs yet9 */10 11const fs = require('node:fs');12const path = require('node:path');13 14function die(msg) {15 process.stderr.write(`${msg}\n`);16 process.exit(1);17}18 19function slugify(text) {20 const t = String(text || '')21 .trim()22 .toLowerCase();23 const noQuotes = t.replace(/['"`]/g, '');24 const dashed = noQuotes.replace(/[^a-z0-9]+/g, '-').replace(/-{2,}/g, '-');25 const trimmed = dashed.replace(/^-+/, '').replace(/-+$/, '');26 return trimmed || 'decision';27}28 29function toPosix(p) {30 return p.split(path.sep).join('/');31}32 33function parseArgs(argv) {34 const out = {35 repoRoot: '.',36 dir: null,37 noCreateDir: false,38 title: null,39 status: 'proposed',40 template: 'simple', // simple | madr41 strategy: 'auto', // auto | date | slug42 deciders: '',43 consulted: '',44 informed: '',45 technicalStory: '',46 chosenOption: '',47 updateIndex: false,48 indexFile: null,49 json: false,50 };51 52 for (let i = 2; i < argv.length; i++) {53 const a = argv[i];54 const next = () => {55 if (i + 1 >= argv.length) die(`Missing value for ${a}`);56 return argv[++i];57 };58 59 if (a === '--repo-root') out.repoRoot = next();60 else if (a === '--dir') out.dir = next();61 else if (a === '--no-create-dir') out.noCreateDir = true;62 else if (a === '--title') out.title = next();63 else if (a === '--status') out.status = next();64 else if (a === '--template') out.template = next();65 else if (a === '--strategy') out.strategy = next();66 else if (a === '--deciders') out.deciders = next();67 else if (a === '--consulted') out.consulted = next();68 else if (a === '--informed') out.informed = next();69 else if (a === '--technical-story') out.technicalStory = next();70 else if (a === '--chosen-option') out.chosenOption = next();71 else if (a === '--update-index') out.updateIndex = true;72 else if (a === '--index-file') out.indexFile = next();73 else if (a === '--json') out.json = true;74 else if (a === '--help' || a === '-h') {75 process.stdout.write(76 [77 'Usage: node new_adr.js --title "Choose database" [options]',78 '',79 'Options:',80 ' --repo-root <path> Repo root (default: .)',81 ' --dir <path> ADR directory (default: auto-detect, else adr/)',82 ' --no-create-dir Do not create ADR directory if missing',83 ' --status <value> ADR status (default: proposed)',84 ' --template simple|madr Template (default: simple)',85 ' --strategy auto|date|slug Filename strategy (default: auto)',86 ' --deciders "a,b" Deciders list',87 ' --consulted "a,b" Consulted experts (RACI)',88 ' --informed "a,b" Informed stakeholders (RACI)',89 ' --technical-story <x> Issue/ticket/PR link or short ref',90 ' --chosen-option <x> MADR template: chosen option label',91 ' --update-index Update adr/README.md (or existing index)',92 ' --index-file <path> Override index file (relative to repo root unless absolute)',93 ' --json Output machine-readable JSON (default: off)',94 '',95 ].join('\n'),96 );97 process.exit(0);98 } else {99 die(`Unknown arg: ${a}`);100 }101 }102 103 if (!out.title) die('Missing required --title');104 105 if (!['simple', 'madr'].includes(out.template))106 die(`Invalid --template: ${out.template}`);107 if (!['auto', 'date', 'slug'].includes(out.strategy))108 die(`Invalid --strategy: ${out.strategy}`);109 110 return out;111}112 113function detectAdrDir(repoRoot) {114 const candidates = [115 path.join(repoRoot, 'contributing', 'decisions'),116 path.join(repoRoot, 'docs', 'decisions'),117 path.join(repoRoot, 'adr'),118 path.join(repoRoot, 'docs', 'adr'),119 path.join(repoRoot, 'docs', 'adrs'),120 path.join(repoRoot, 'decisions'),121 ];122 for (const p of candidates) {123 try {124 if (fs.statSync(p).isDirectory()) return p;125 } catch {126 // ignore127 }128 }129 return null;130}131 132function listMdFiles(dir) {133 let entries = [];134 try {135 entries = fs.readdirSync(dir, { withFileTypes: true });136 } catch {137 return [];138 }139 return entries140 .filter(e => e.isFile() && e.name.toLowerCase().endsWith('.md'))141 .map(e => e.name);142}143 144function detectStrategy(adrDir) {145 const md = listMdFiles(adrDir);146 for (const name of md) {147 if (/^\d{4}-\d{2}-\d{2}-/.test(name)) return 'date';148 }149 if (md.length > 0) return 'slug';150 return 'date';151}152 153function todayISO() {154 return new Date().toISOString().slice(0, 10);155}156 157function loadTemplate(templateName) {158 const skillRoot = path.resolve(__dirname, '..');159 const templatePath = path.join(160 skillRoot,161 'assets',162 'templates',163 `adr-${templateName}.md`,164 );165 if (!fs.existsSync(templatePath)) die(`Template not found: ${templatePath}`);166 return fs.readFileSync(templatePath, 'utf8');167}168 169function renderTemplate(raw, vars) {170 // Handle YAML front matter placeholders (quoted and unquoted)171 let out = raw;172 173 // YAML front matter fields — replace the whole placeholder pattern174 // e.g. status: "{proposed | accepted | ...}" → status: proposed175 out = out.replace(176 /^(status:\s*)["']?\{[^}]*\}["']?\s*$/m,177 `$1${vars.status}`,178 );179 out = out.replace(/^(date:\s*)\{[^}]*\}\s*$/m, `$1${vars.date}`);180 out = out.replace(181 /^(decision-makers:\s*)["']?\{[^}]*\}["']?\s*$/m,182 `$1${vars.deciders || ''}`,183 );184 185 // consulted / informed: replace if a value was provided, otherwise remove the186 // entire line so we don't leak placeholder text like "{list everyone...}"187 if (vars.consulted) {188 out = out.replace(189 /^(consulted:\s*)["']?\{[^}]*\}["']?\s*$/m,190 `$1${vars.consulted}`,191 );192 } else {193 out = out.replace(/^consulted:\s*["']?\{[^}]*\}["']?\s*\n/m, '');194 }195 if (vars.informed) {196 out = out.replace(197 /^(informed:\s*)["']?\{[^}]*\}["']?\s*$/m,198 `$1${vars.informed}`,199 );200 } else {201 out = out.replace(/^informed:\s*["']?\{[^}]*\}["']?\s*\n/m, '');202 }203 204 // Replace MADR-style heading placeholder205 out = out.replace(/^(#\s+)\{short title[^}]*\}\s*$/m, `$1${vars.title}`);206 207 // Inline placeholders (title in heading, etc.)208 out = out209 .replaceAll('{TITLE}', vars.title)210 .replaceAll('{STATUS}', vars.status)211 .replaceAll('{DATE}', vars.date)212 .replaceAll('{DECIDERS}', vars.deciders)213 .replaceAll('{TECHNICAL_STORY}', vars.technicalStory)214 .replaceAll('{CHOSEN_OPTION}', vars.chosenOption);215 216 return out;217}218 219function chooseIndexFile(adrDir) {220 for (const name of ['README.md', 'index.md']) {221 const p = path.join(adrDir, name);222 if (fs.existsSync(p)) return p;223 }224 return path.join(adrDir, 'README.md');225}226 227function insertIndexEntryUnderHeading(lines, headingRegex, entryLine) {228 // Returns { lines, inserted }229 const headingIndex = lines.findIndex(l => headingRegex.test(l));230 if (headingIndex === -1) return { lines, inserted: false };231 232 let sectionEnd = lines.length;233 for (let i = headingIndex + 1; i < lines.length; i++) {234 if (/^##\s+/.test(lines[i])) {235 sectionEnd = i;236 break;237 }238 }239 240 // Prefer inserting at end of list in this section if there is a list.241 let lastListItem = -1;242 for (let i = sectionEnd - 1; i > headingIndex; i--) {243 if (/^[-*]\s+/.test(lines[i])) {244 lastListItem = i;245 break;246 }247 }248 249 const insertAt = lastListItem !== -1 ? lastListItem + 1 : sectionEnd;250 251 const out = [...lines];252 253 // Ensure there's a blank line after the heading if we're inserting immediately after it.254 if (insertAt === headingIndex + 1 && out[insertAt] !== '') {255 out.splice(insertAt, 0, '');256 }257 258 out.splice(insertAt, 0, entryLine);259 return { lines: out, inserted: true };260}261 262function updateIndex(indexFile, { relLink, title, status, date }) {263 let content = '';264 if (fs.existsSync(indexFile)) content = fs.readFileSync(indexFile, 'utf8');265 else content = '# ADR Log\n\n';266 267 if (content.includes(relLink)) return false;268 269 const normalized = content.replace(/\r\n/g, '\n');270 const hadTrailingNewline = normalized.endsWith('\n');271 let lines = normalized.split('\n');272 // Normalize away the trailing empty split element so insertion math is sane.273 if (274 hadTrailingNewline &&275 lines.length > 0 &&276 lines[lines.length - 1] === ''277 ) {278 lines = lines.slice(0, -1);279 }280 const entryLine = `- [${title}](${relLink}) (${status}, ${date})`;281 282 // Prefer inserting under "## ADRs" if it exists, otherwise append at EOF.283 const r = insertIndexEntryUnderHeading(lines, /^##\s+ADRs\s*$/i, entryLine);284 const nextLines = r.inserted ? r.lines : [...lines, entryLine];285 286 let next = nextLines.join('\n');287 if (hadTrailingNewline) next += '\n';288 289 fs.mkdirSync(path.dirname(indexFile), { recursive: true });290 fs.writeFileSync(indexFile, next, 'utf8');291 return true;292}293 294function main() {295 const args = parseArgs(process.argv);296 297 const repoRoot = path.resolve(process.cwd(), args.repoRoot);298 if (!fs.existsSync(repoRoot)) die(`Repo root does not exist: ${repoRoot}`);299 300 let adrDir;301 if (args.dir) adrDir = path.resolve(repoRoot, args.dir);302 else adrDir = detectAdrDir(repoRoot) || path.join(repoRoot, 'adr');303 304 if (!fs.existsSync(adrDir)) {305 if (args.noCreateDir) die(`ADR directory does not exist: ${adrDir}`);306 fs.mkdirSync(adrDir, { recursive: true });307 }308 309 let strategy = args.strategy;310 if (strategy === 'auto') strategy = detectStrategy(adrDir);311 312 const title = String(args.title).trim();313 const slug = slugify(title);314 315 const today = todayISO();316 317 let filename;318 if (strategy === 'date') {319 filename = `${today}-${slug}.md`;320 } else {321 filename = `${slug}.md`;322 }323 324 let out = path.join(adrDir, filename);325 if (fs.existsSync(out)) {326 if (strategy === 'date') die(`ADR already exists: ${out}`);327 let i = 2;328 while (true) {329 const candidate = path.join(adrDir, `${slug}-${i}.md`);330 if (!fs.existsSync(candidate)) {331 out = candidate;332 break;333 }334 i++;335 }336 }337 338 const deciders = String(args.deciders || '')339 .split(',')340 .map(s => s.trim())341 .filter(Boolean)342 .join(', ');343 344 const consulted = String(args.consulted || '')345 .split(',')346 .map(s => s.trim())347 .filter(Boolean)348 .join(', ');349 const informed = String(args.informed || '')350 .split(',')351 .map(s => s.trim())352 .filter(Boolean)353 .join(', ');354 355 const raw = loadTemplate(args.template);356 const rendered = renderTemplate(raw, {357 title,358 status: String(args.status).trim(),359 date: today,360 deciders,361 consulted,362 informed,363 technicalStory: String(args.technicalStory || '').trim(),364 chosenOption: String(args.chosenOption || '').trim(),365 });366 367 fs.writeFileSync(out, `${rendered.trimEnd()}\n`, 'utf8');368 369 let updatedIndexPath = null;370 let indexChanged = false;371 372 if (args.updateIndex) {373 let indexFile;374 if (args.indexFile) {375 indexFile = path.isAbsolute(args.indexFile)376 ? args.indexFile377 : path.resolve(repoRoot, args.indexFile);378 } else {379 indexFile = chooseIndexFile(adrDir);380 }381 382 const relLink = toPosix(path.relative(path.dirname(indexFile), out));383 indexChanged = updateIndex(indexFile, {384 relLink,385 title,386 status: String(args.status).trim(),387 date: today,388 });389 updatedIndexPath = indexFile;390 }391 392 if (args.json) {393 const payload = {394 repoRoot,395 adrDir,396 createdAdrPath: out,397 createdAdrRelPath: toPosix(path.relative(repoRoot, out)),398 title,399 status: String(args.status).trim(),400 template: args.template,401 strategy,402 date: today,403 indexUpdated: Boolean(updatedIndexPath),404 indexChanged,405 indexPath: updatedIndexPath,406 indexRelPath: updatedIndexPath407 ? toPosix(path.relative(repoRoot, updatedIndexPath))408 : null,409 };410 process.stdout.write(`${JSON.stringify(payload)}\n`);411 } else {412 process.stdout.write(`${out}\n`);413 }414}415 416main();417 Referenced from SKILL.md
SKILL.mdView in source ↗
Source excerpt starting at line 138.SKILL.mdView in source ↗1387. **Generate the file.**139 - Preferred: run `scripts/new_adr.js` (handles directory, naming, and optional index updates).140 - If you can't run scripts, copy a template from `assets/templates/` and fill it manually.
Source excerpt starting at line 250.SKILL.mdView in source ↗250Preferred: let `scripts/new_adr.js --update-index` do it. Otherwise:
Source excerpt starting at line 285.285- `scripts/new_adr.js` — create a new ADR file from a template, using repo conventions.286- `scripts/set_adr_status.js` — update an ADR status in-place (YAML front matter or inline). Use `--json` for machine output.