resuml 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -409,56 +409,86 @@ declare namespace themeRender {
409
409
  export { themeRender_injectCss as injectCss, themeRender_renderTheme as renderTheme };
410
410
  }
411
411
 
412
- type AtsCheckCategory = 'contact' | 'content' | 'structure' | 'keywords';
412
+ type Tier = 'parsing' | 'match' | 'recruiter';
413
413
  type AtsCheckWeight = 'high' | 'medium' | 'low';
414
414
  type AtsRating = 'excellent' | 'good' | 'needs-work' | 'poor';
415
- type AtsFitLevel = 'strong' | 'partial' | 'weak';
416
- interface AtsCheck {
415
+ type CheckStatus = 'pass' | 'warn' | 'fail' | 'skipped';
416
+ type Grade = 'A' | 'B' | 'C' | 'D' | 'F';
417
+ interface CheckResult {
417
418
  id: string;
418
- category: AtsCheckCategory;
419
- weight: AtsCheckWeight;
420
- passed: boolean;
419
+ tier: Tier;
420
+ status: CheckStatus;
421
421
  score: number;
422
+ weight: AtsCheckWeight;
422
423
  message: string;
423
- suggestion?: string;
424
+ hints: string[];
424
425
  }
425
- interface AtsKeywordMatch {
426
- matched: string[];
427
- missing: string[];
428
- extra: string[];
429
- matchPercentage: number;
426
+ interface TierResult {
427
+ score: number;
428
+ grade: Grade;
429
+ checks: CheckResult[];
430
430
  }
431
- interface AtsFitAssessment {
432
- level: AtsFitLevel;
433
- message: string;
431
+ interface KnockoutSignal {
432
+ signal: string;
433
+ evidence: string;
434
+ recommendation: string;
434
435
  }
435
- interface AtsResult {
436
+ interface TieredAtsResult {
436
437
  score: number;
437
438
  rating: AtsRating;
438
- checks: AtsCheck[];
439
- keywords?: AtsKeywordMatch;
440
- fitAssessment?: AtsFitAssessment;
439
+ tiers: {
440
+ parsing: TierResult;
441
+ match?: TierResult;
442
+ recruiter: TierResult;
443
+ };
444
+ knockouts: KnockoutSignal[];
441
445
  summary: string;
442
446
  }
443
447
  interface AtsOptions {
444
448
  language?: string;
445
449
  jobDescription?: string;
446
450
  threshold?: number;
451
+ config?: AtsConfig;
452
+ }
453
+ interface AtsConfig {
454
+ weights: {
455
+ tiers: {
456
+ parsing: number;
457
+ match: number;
458
+ recruiter: number;
459
+ };
460
+ checks: Record<string, AtsCheckWeight>;
461
+ };
462
+ thresholds: {
463
+ rating: {
464
+ excellent: number;
465
+ good: number;
466
+ needsWork: number;
467
+ };
468
+ grade: {
469
+ A: number;
470
+ B: number;
471
+ C: number;
472
+ D: number;
473
+ };
474
+ seniorYoeCutoff: number;
475
+ wordCount: {
476
+ min: number;
477
+ max: number;
478
+ seniorMax: number;
479
+ };
480
+ bulletsPerRole: {
481
+ min: number;
482
+ max: number;
483
+ seniorMax: number;
484
+ };
485
+ };
486
+ disable: string[];
487
+ locale: string;
447
488
  }
448
489
 
449
- /**
450
- * Run ATS analysis on a resume.
451
- *
452
- * Performs deterministic, offline checks:
453
- * 1. Generic best-practice checks (contact, content, structure)
454
- * 2. Optional job-description keyword matching
455
- *
456
- * @param resume - Validated resume data
457
- * @param options - ATS analysis options
458
- * @returns Full ATS analysis result with score, checks, and suggestions
459
- */
460
- declare function analyzeAts(resume: ResumeSchema, options?: AtsOptions): AtsResult;
490
+ declare function analyzeAts(resume: ResumeSchema, options?: AtsOptions): TieredAtsResult;
461
491
 
462
492
  declare const program: Command;
463
493
 
464
- export { type AtsOptions, type AtsResult, analyzeAts, loadResumeFiles, loadTheme, processResumeData, program, themeRender };
494
+ export { type AtsOptions, type TieredAtsResult, analyzeAts, loadResumeFiles, loadTheme, processResumeData, program, themeRender };
package/dist/index.js CHANGED
@@ -5,10 +5,13 @@ import {
5
5
  analyzeAts,
6
6
  generateResumeYaml,
7
7
  getInstalledVersion,
8
+ getRubricEntry,
8
9
  isThemeInstalled,
10
+ listRubricMarkdown,
11
+ loadConfig,
9
12
  loadTheme,
10
13
  processResumeData
11
- } from "./chunk-GRIYYG45.js";
14
+ } from "./chunk-R4MD5YMV.js";
12
15
 
13
16
  // src/index.ts
14
17
  import { Command } from "commander";
@@ -129,97 +132,85 @@ function handleCommandError(error, command, debug = false) {
129
132
  }
130
133
 
131
134
  // src/commands/validate.ts
132
- function formatAtsReport(result, debug, chalk6) {
133
- const scoreColor = result.score >= 75 ? chalk6.green : result.score >= 60 ? chalk6.yellow : chalk6.red;
135
+ function formatAtsReport(result, debug, chalk7) {
136
+ const scoreColor = result.score >= 75 ? chalk7.green : result.score >= 60 ? chalk7.yellow : chalk7.red;
134
137
  console.log("");
135
- console.log(chalk6.bold("\u2550\u2550\u2550 ATS Analysis Report \u2550\u2550\u2550"));
138
+ console.log(chalk7.bold("=== ATS Analysis Report ==="));
136
139
  console.log("");
137
- console.log(` Score: ${scoreColor(chalk6.bold(`${result.score}/100`))} (${result.rating.replace("-", " ")})`);
140
+ console.log(
141
+ ` Score: ${scoreColor(chalk7.bold(`${result.score}/100`))} (${result.rating.replace("-", " ")})`
142
+ );
138
143
  console.log(` ${result.summary}`);
139
144
  console.log("");
140
- const categories = {};
141
- for (const check of result.checks) {
142
- const list = categories[check.category];
143
- if (!list) {
144
- categories[check.category] = [check];
145
- } else {
146
- list.push(check);
147
- }
148
- }
149
- const categoryLabels = {
150
- contact: "Contact Information",
151
- content: "Content Quality",
152
- structure: "Resume Structure",
153
- keywords: "Keywords"
145
+ const tierLabels = {
146
+ parsing: "Parsing",
147
+ recruiter: "Recruiter",
148
+ match: "JD Match"
154
149
  };
155
- for (const [cat, checks] of Object.entries(categories)) {
156
- const label = categoryLabels[cat] || cat;
157
- console.log(chalk6.bold(` ${label}`));
158
- for (const check of checks) {
159
- if (!debug && check.passed) continue;
160
- const icon = check.passed ? chalk6.green("\u2713") : chalk6.red("\u2717");
161
- const scoreText = chalk6.dim(`[${check.score}]`);
150
+ for (const [tierName, tier] of Object.entries(result.tiers)) {
151
+ const label = tierLabels[tierName] ?? tierName;
152
+ console.log(chalk7.bold(` ${label} (${tier.score}/100, grade ${tier.grade})`));
153
+ for (const check of tier.checks) {
154
+ if (!debug && (check.status === "pass" || check.status === "skipped")) continue;
155
+ const icon = check.status === "pass" ? chalk7.green("v") : check.status === "skipped" ? chalk7.dim("-") : check.status === "warn" ? chalk7.yellow("!") : chalk7.red("x");
156
+ const scoreText = chalk7.dim(`[${check.score}]`);
162
157
  console.log(` ${icon} ${check.message} ${scoreText}`);
163
- if (!check.passed && check.suggestion) {
164
- console.log(chalk6.dim(` \u2192 ${check.suggestion}`));
158
+ for (const hint of check.hints) {
159
+ console.log(chalk7.dim(` -> ${hint}`));
165
160
  }
166
161
  }
167
162
  console.log("");
168
163
  }
169
- if (result.keywords) {
170
- console.log(chalk6.bold(" Job Description Match"));
171
- const kw = result.keywords;
172
- const matchColor = kw.matchPercentage >= 70 ? chalk6.green : kw.matchPercentage >= 50 ? chalk6.yellow : chalk6.red;
173
- console.log(` Match: ${matchColor(`${kw.matchPercentage}%`)} (${kw.matched.length}/${kw.matched.length + kw.missing.length} keywords)`);
174
- if (kw.matched.length > 0) {
175
- console.log(chalk6.green(` \u2713 Matched: ${kw.matched.join(", ")}`));
176
- }
177
- if (kw.missing.length > 0) {
178
- console.log(chalk6.red(` \u2717 Missing: ${kw.missing.join(", ")}`));
179
- console.log(chalk6.dim(" \u2192 Consider incorporating these keywords into your resume where relevant."));
164
+ if (result.knockouts.length > 0) {
165
+ console.log(chalk7.bold(" Knockout Signals"));
166
+ for (const k of result.knockouts) {
167
+ console.log(chalk7.red(` ! ${k.signal}: ${k.evidence}`));
168
+ console.log(chalk7.dim(` -> ${k.recommendation}`));
180
169
  }
181
170
  console.log("");
182
171
  }
183
- console.log(chalk6.dim("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550"));
172
+ console.log(chalk7.dim("==========================="));
184
173
  }
185
174
  async function validateAction(options) {
186
- const chalk6 = (await import("chalk")).default;
187
- console.log(chalk6.blue("Starting resuml validate..."));
175
+ const chalk7 = (await import("chalk")).default;
176
+ console.log(chalk7.blue("Starting resuml validate..."));
188
177
  try {
189
178
  const inputPath = options.resume;
190
179
  const { yamlContents } = await loadResumeFiles(inputPath);
191
- console.log(chalk6.blue("Validating resume data..."));
180
+ console.log(chalk7.blue("Validating resume data..."));
192
181
  let resumeData;
193
182
  try {
194
183
  resumeData = await processResumeData(yamlContents);
195
- console.log(chalk6.green("\u2713 Resume data is valid against the schema!"));
184
+ console.log(chalk7.green("\u2713 Resume data is valid against the schema!"));
196
185
  } catch (error) {
197
186
  handleCommandError(error, "validate", options.debug);
198
187
  return;
199
188
  }
200
189
  if (options.ats) {
201
- console.log(chalk6.blue("Running ATS analysis..."));
190
+ console.log(chalk7.blue("Running ATS analysis..."));
202
191
  let jobDescription;
203
192
  if (options.jd) {
204
193
  try {
205
194
  jobDescription = fs3.readFileSync(options.jd, "utf8");
206
195
  } catch {
207
- console.error(chalk6.red(`Failed to read job description file: ${options.jd}`));
196
+ console.error(chalk7.red(`Failed to read job description file: ${options.jd}`));
208
197
  return;
209
198
  }
210
199
  }
200
+ const cfg = loadConfig(options.config ? { configPath: options.config } : {});
211
201
  const result = analyzeAts(resumeData, {
212
- language: "en",
213
- jobDescription
202
+ language: cfg.locale,
203
+ jobDescription,
204
+ config: cfg
214
205
  });
215
206
  if (options.format === "json") {
216
207
  console.log(JSON.stringify(result, null, 2));
217
208
  } else {
218
- formatAtsReport(result, !!options.debug, chalk6);
209
+ formatAtsReport(result, !!options.debug, chalk7);
219
210
  }
220
211
  const threshold = options.atsThreshold ? parseInt(options.atsThreshold, 10) : void 0;
221
212
  if (threshold !== void 0 && result.score < threshold) {
222
- console.error(chalk6.red(`
213
+ console.error(chalk7.red(`
223
214
  ATS score ${result.score} is below threshold ${threshold}.`));
224
215
  process.exit(1);
225
216
  }
@@ -232,17 +223,17 @@ ATS score ${result.score} is below threshold ${threshold}.`));
232
223
  // src/commands/tojson.ts
233
224
  import fs4 from "fs";
234
225
  async function toJsonAction(options) {
235
- const chalk6 = (await import("chalk")).default;
236
- console.log(chalk6.blue("Starting resuml tojson..."));
226
+ const chalk7 = (await import("chalk")).default;
227
+ console.log(chalk7.blue("Starting resuml tojson..."));
237
228
  try {
238
229
  const inputPath = options.resume;
239
230
  const { yamlContents } = await loadResumeFiles(inputPath);
240
- console.log(chalk6.blue("Processing and validating data..."));
231
+ console.log(chalk7.blue("Processing and validating data..."));
241
232
  const resumeData = await processResumeData(yamlContents);
242
- console.log(chalk6.green("Processing and validation successful!"));
233
+ console.log(chalk7.green("Processing and validation successful!"));
243
234
  const jsonOutput = JSON.stringify(resumeData, null, 2);
244
235
  fs4.writeFileSync(options.output, jsonOutput, "utf8");
245
- console.log(chalk6.green(`Successfully wrote output to ${options.output}`));
236
+ console.log(chalk7.green(`Successfully wrote output to ${options.output}`));
246
237
  } catch (error) {
247
238
  handleCommandError(error, "tojson", options.debug);
248
239
  }
@@ -454,15 +445,37 @@ async function initAction(options) {
454
445
  const name = await ask(rl, "Your full name", "John Doe");
455
446
  const email = await ask(rl, "Email address", "john@example.com");
456
447
  const label = await ask(rl, "Professional title/label", "Software Engineer");
457
- const yaml = generateResumeYaml(name, email, label);
448
+ const yaml2 = generateResumeYaml(name, email, label);
458
449
  fs7.mkdirSync(path4.dirname(fullPath), { recursive: true });
459
- fs7.writeFileSync(fullPath, yaml, "utf8");
450
+ fs7.writeFileSync(fullPath, yaml2, "utf8");
451
+ const configPath = path4.join(path4.dirname(fullPath), "resuml.config.yaml");
452
+ const template = `# resuml ATS configuration
453
+ # Override defaults; everything is optional.
454
+ # Run \`resuml ats config --print\` to see the merged effective config.
455
+ ats:
456
+ weights:
457
+ tiers:
458
+ parsing: 30
459
+ match: 50
460
+ recruiter: 20
461
+ thresholds:
462
+ seniorYoeCutoff: 10
463
+ disable: []
464
+ `;
465
+ try {
466
+ fs7.writeFileSync(configPath, template, { encoding: "utf8", flag: "wx" });
467
+ console.log(chalk3.green(`Created resuml.config.yaml`));
468
+ } catch (err) {
469
+ if (err.code !== "EEXIST") throw err;
470
+ }
460
471
  console.log(chalk3.green(`
461
472
  \u2705 Created ${outputPath}`));
462
473
  console.log(chalk3.blue("\nNext steps:"));
463
474
  console.log(` 1. Edit ${outputPath} to fill in your details`);
464
475
  console.log(" 2. Run " + chalk3.cyan("resuml validate --resume " + outputPath));
465
- console.log(" 3. Run " + chalk3.cyan("resuml render --resume " + outputPath + " --theme stackoverflow"));
476
+ console.log(
477
+ " 3. Run " + chalk3.cyan("resuml render --resume " + outputPath + " --theme stackoverflow")
478
+ );
466
479
  } finally {
467
480
  rl.close();
468
481
  }
@@ -525,6 +538,30 @@ async function pdfAction(options) {
525
538
  try {
526
539
  const page = await browser.newPage();
527
540
  await page.setContent(htmlOutput, { waitUntil: "networkidle" });
541
+ const bodyText = await page.evaluate(() => document.body.innerText || "");
542
+ const bodyWords = bodyText.trim().split(/\s+/).filter(Boolean).length;
543
+ const resumeContentParts = [];
544
+ if (resumeData.basics?.summary) resumeContentParts.push(resumeData.basics.summary);
545
+ for (const w of resumeData.work || []) {
546
+ if (w.summary) resumeContentParts.push(w.summary);
547
+ resumeContentParts.push(...w.highlights || []);
548
+ }
549
+ for (const p of resumeData.projects || []) {
550
+ if (p.description) resumeContentParts.push(p.description);
551
+ resumeContentParts.push(...p.highlights || []);
552
+ }
553
+ for (const s of resumeData.skills || []) {
554
+ if (s.name) resumeContentParts.push(s.name);
555
+ resumeContentParts.push(...s.keywords || []);
556
+ }
557
+ const resumeWords = resumeContentParts.join(" ").split(/\s+/).filter(Boolean).length;
558
+ if (resumeWords > 0 && bodyWords < resumeWords * 0.7) {
559
+ console.warn(
560
+ chalk4.yellow(
561
+ `pdf-text-extractable: rendered text ${bodyWords} words vs resume ${resumeWords} (under 70%). Theme may use image-based glyphs.`
562
+ )
563
+ );
564
+ }
528
565
  const pdfBuffer = await page.pdf({
529
566
  format,
530
567
  margin,
@@ -533,6 +570,14 @@ async function pdfAction(options) {
533
570
  });
534
571
  fs8.mkdirSync(path5.dirname(path5.resolve(outputPath)), { recursive: true });
535
572
  fs8.writeFileSync(outputPath, pdfBuffer);
573
+ const sizeMb = pdfBuffer.length / (1024 * 1024);
574
+ if (sizeMb > 2.5) {
575
+ console.warn(
576
+ chalk4.yellow(
577
+ `pdf-size-under-2.5mb: PDF is ${sizeMb.toFixed(2)} MB (Greenhouse limit 2.5 MB).`
578
+ )
579
+ );
580
+ }
536
581
  console.log(chalk4.green(`\u2705 Successfully generated ${outputPath}`));
537
582
  } finally {
538
583
  await browser.close();
@@ -552,7 +597,9 @@ function listThemes() {
552
597
  console.log(
553
598
  ` ${"Status".padEnd(10)}${"Name".padEnd(nameWidth)}${"Package".padEnd(pkgWidth)}Description`
554
599
  );
555
- console.log(` ${"\u2500".repeat(10)}${"\u2500".repeat(nameWidth)}${"\u2500".repeat(pkgWidth)}${"\u2500".repeat(30)}`);
600
+ console.log(
601
+ ` ${"\u2500".repeat(10)}${"\u2500".repeat(nameWidth)}${"\u2500".repeat(pkgWidth)}${"\u2500".repeat(30)}`
602
+ );
556
603
  for (const theme of KNOWN_THEMES) {
557
604
  const installed = isThemeInstalled(theme.pkg);
558
605
  const version = installed ? getInstalledVersion(theme.pkg) : null;
@@ -579,14 +626,18 @@ function installTheme(name) {
579
626
  execSync(`npm install ${pkg}`, { stdio: "inherit" });
580
627
  console.log(chalk5.green(`
581
628
  \u2705 Successfully installed ${pkg}`));
582
- console.log(chalk5.blue(`
629
+ console.log(
630
+ chalk5.blue(`
583
631
  Use it with: ${chalk5.cyan(`resuml render --theme ${known?.name || name}`)}
584
- `));
632
+ `)
633
+ );
585
634
  } catch {
586
635
  console.error(chalk5.red(`
587
636
  \u274C Failed to install ${pkg}`));
588
- console.error(chalk5.yellow(`Make sure the package exists: https://www.npmjs.com/package/${pkg}
589
- `));
637
+ console.error(
638
+ chalk5.yellow(`Make sure the package exists: https://www.npmjs.com/package/${pkg}
639
+ `)
640
+ );
590
641
  }
591
642
  }
592
643
  function themesAction(options) {
@@ -603,6 +654,32 @@ async function mcpAction() {
603
654
  await startMcpServer();
604
655
  }
605
656
 
657
+ // src/commands/ats.ts
658
+ import yaml from "yaml";
659
+ import chalk6 from "chalk";
660
+ function atsExplain(id) {
661
+ if (!id) {
662
+ console.log(listRubricMarkdown());
663
+ return;
664
+ }
665
+ const entry = getRubricEntry(id);
666
+ if (!entry) {
667
+ console.error(chalk6.red(`Unknown rubric id: ${id}`));
668
+ process.exit(1);
669
+ return;
670
+ }
671
+ console.log(chalk6.bold(entry.id));
672
+ console.log(` Tier: ${entry.tier}`);
673
+ console.log(` Weight: ${entry.weight}`);
674
+ console.log(` Evidence: ${entry.evidenceLevel}`);
675
+ console.log(` Description: ${entry.description}`);
676
+ if (entry.source) console.log(` Source: ${entry.source}`);
677
+ }
678
+ function atsConfigPrint(opts) {
679
+ const cfg = loadConfig(opts.config ? { configPath: opts.config } : {});
680
+ console.log(yaml.stringify({ ats: cfg }));
681
+ }
682
+
606
683
  // src/utils/themeRender.ts
607
684
  var themeRender_exports = {};
608
685
  __export(themeRender_exports, {
@@ -671,7 +748,10 @@ function getCliVersion() {
671
748
  }
672
749
  var program = new Command();
673
750
  program.name("resuml").description("CLI tool for managing resuml resume files.").version(getCliVersion());
674
- program.command("validate").description("Validates resume data against the schema.").option("-r, --resume <path>", "Input YAML file, directory, or glob pattern.").option("--debug", "Show detailed validation errors.").option("--ats", "Run ATS (Applicant Tracking System) compatibility analysis.").option("--jd <path>", "Path to a job description file for keyword matching (requires --ats).").option("--ats-threshold <score>", "Minimum ATS score (0-100). Exit with code 1 if below threshold.").option("--format <type>", "Output format for ATS results (text or json).", "text").action(validateAction);
751
+ program.command("validate").description("Validates resume data against the schema.").option("-r, --resume <path>", "Input YAML file, directory, or glob pattern.").option("--debug", "Show detailed validation errors.").option("--ats", "Run ATS (Applicant Tracking System) compatibility analysis.").option("--jd <path>", "Path to a job description file for keyword matching (requires --ats).").option(
752
+ "--ats-threshold <score>",
753
+ "Minimum ATS score (0-100). Exit with code 1 if below threshold."
754
+ ).option("--format <type>", "Output format for ATS results (text or json).", "text").option("--config <path>", "Path to resuml.config.yaml (default: ./resuml.config.yaml).").action(validateAction);
675
755
  program.command("tojson").description("Converts YAML resume data to JSON format.").option("-r, --resume <path>", "Input YAML file, directory, or glob pattern.").option("-o, --output <file>", "Output JSON file path.", "resume.json").option("--debug", "Show detailed validation and processing information.").action(toJsonAction);
676
756
  program.command("render").description("Renders the resume data using a specified theme.").option("-r, --resume <path>", "Input YAML file, directory, or glob pattern.").option("-t, --theme <name>", "Theme name (e.g., stackoverflow, react).").option("-o, --output <file>", "Output file path.").option("--format <type>", "Output format (html or pdf).", "html").option("--language <code>", "Language code for localization.", "en").option("--debug", "Show detailed validation and processing information.").action(renderAction);
677
757
  program.command("dev").description("Start development server with hot-reload.").option("-r, --resume <path>", "Input YAML file, directory, or glob pattern.").option("-t, --theme <name>", "Theme name (e.g., stackoverflow, react).").option("--port <number>", "Port for development server.", "3000").option("--language <code>", "Language code for localization.", "en").option("--debug", "Show detailed validation and processing information.").action(devAction);
@@ -679,6 +759,13 @@ program.command("init").description("Scaffold a starter resume.yaml file with al
679
759
  program.command("pdf").description("Export resume as PDF using Playwright.").option("-r, --resume <path>", "Input YAML file, directory, or glob pattern.").option("-t, --theme <name>", "Theme name (e.g., stackoverflow, react).").option("-o, --output <file>", "Output PDF file path.", "resume.pdf").option("--language <code>", "Language code for localization.", "en").option("--format <size>", "Page format: A4 or Letter.", "A4").option("--margin <values>", 'Page margins (e.g., "10mm" or "10mm,15mm,10mm,15mm").').option("--debug", "Show detailed validation and processing information.").action(pdfAction);
680
760
  program.command("themes").description("List available JSON Resume themes and install them.").option("--install <name>", "Install a theme by name (e.g., stackoverflow, elegant).").action(themesAction);
681
761
  program.command("mcp").description("Start MCP server for AI agent integration (stdio transport).").action(mcpAction);
762
+ var ats = program.command("ats").description("ATS rubric utilities.");
763
+ ats.command("explain [id]").description("Print rubric entry for a check id, or full rubric if id omitted.").action((id) => {
764
+ atsExplain(id);
765
+ });
766
+ ats.command("config").description("Print the effective merged ATS config.").option("--print", "Print the merged config (default action).").option("--config <path>", "Path to resuml.config.yaml.").action((opts) => {
767
+ atsConfigPrint(opts);
768
+ });
682
769
  if (process.env["NODE_ENV"] !== "test") {
683
770
  void (async () => {
684
771
  try {