From f2ebf8b3bb9fdc33600aafd1b4cd7d87b9aa9652 Mon Sep 17 00:00:00 2001
From: avivkeller <me@aviv.sh>
Date: Tue, 15 Apr 2025 17:50:27 -0400
Subject: [PATCH 1/7] feat(cli): split cli into sub commands

---
 README.md         |  73 +++++--
 bin/cli.mjs       | 535 ++++++++++++++++++++++++++++++++++------------
 eslint.config.mjs |   2 +-
 package-lock.json |  34 +++
 package.json      |   1 +
 5 files changed, 490 insertions(+), 155 deletions(-)

diff --git a/README.md b/README.md
index 68b822f1..002742d5 100644
--- a/README.md
+++ b/README.md
@@ -23,30 +23,61 @@
 
 ## Usage
 
-Local invocation:
+### `generate`
+
+Generate API documentation from Markdown files.
 
 ```sh
-$ npx api-docs-tooling --help
+npx api-docs-tooling generate [options]
 ```
 
+**Options:**
+
+- `-i, --input <patterns...>` Input file patterns (glob)
+- `--ignore [patterns...]` Files to ignore
+- `-o, --output <dir>` Output directory
+- `-v, --version <semver>` Target Node.js version (default: latest)
+- `-c, --changelog <url>` Changelog file or URL
+- `--git-ref <url>` Git ref/commit URL
+- `-t, --target [modes...]` Generator target(s): `json-simple`, `legacy-html`, etc.
+- `--no-lint` Skip linting before generation
+
+### `lint`
+
+Run the linter on API documentation.
+
+```sh
+npx api-docs-tooling lint [options]
+```
+
+**Options:**
+
+- `-i, --input <patterns...>` Input file patterns (glob)
+- `--ignore [patterns...]` Files to ignore
+- `--disable-rule [rules...]` Disable specific linting rules
+- `--lint-dry-run` Run linter without applying changes
+- `-r, --reporter <reporter>` Reporter format: `console`, `github`, etc.
+
+### `interactive`
+
+Launches a fully interactive CLI prompt to guide you through all available options.
+
+```sh
+npx api-docs-tooling interactive
+```
+
+### `list`
+
+See available modules for each subsystem.
+
+```sh
+npx api-docs-tooling list generators
+npx api-docs-tooling list rules
+npx api-docs-tooling list reporters
+```
+
+### `help`
+
 ```sh
-Usage: api-docs-tooling [options]
-
-CLI tool to generate API documentation of a Node.js project.
-
-Options:
-  -i, --input [patterns...]  Specify input file patterns using glob syntax
-  --ignore [patterns...]     Specify which input files to ignore using glob syntax
-  -o, --output <path>        Specify the relative or absolute output directory
-  -v, --version <semver>     Specify the target version of Node.js, semver compliant (default: "v22.11.0")
-  -c, --changelog <url>      Specify the path (file: or https://) to the CHANGELOG.md file (default:
-                             "https://raw.githubusercontent.com/nodejs/node/HEAD/CHANGELOG.md")
-  -t, --target [mode...]     Set the processing target modes (choices: "json-simple", "legacy-html", "legacy-html-all",
-                             "man-page", "legacy-json", "legacy-json-all", "addon-verify", "api-links", "orama-db")
-  --disable-rule [rule...]   Disable a specific linter rule (choices: "invalid-change-version",
-                             "missing-change-version", "missing-introduced-in", default: [])
-  --lint-dry-run             Run linter in dry-run mode (default: false)
-  --git-ref                  A git ref/commit URL pointing to Node.js
-  -r, --reporter [reporter]  Specify the linter reporter (choices: "console", "github", default: "console")
-  -h, --help                 display help for command
+npx api-docs-tooling help [command]
 ```
diff --git a/bin/cli.mjs b/bin/cli.mjs
index c4248de9..a679d107 100755
--- a/bin/cli.mjs
+++ b/bin/cli.mjs
@@ -2,9 +2,10 @@
 
 import { resolve } from 'node:path';
 import process from 'node:process';
+import { spawnSync } from 'node:child_process';
 import { cpus } from 'node:os';
 
-import { Command, Option } from 'commander';
+import { Argument, Command, Option } from 'commander';
 
 import { coerce } from 'semver';
 import { DOC_NODE_CHANGELOG_URL, DOC_NODE_VERSION } from '../src/constants.mjs';
@@ -17,145 +18,413 @@ import createMarkdownLoader from '../src/loaders/markdown.mjs';
 import createMarkdownParser from '../src/parsers/markdown.mjs';
 import createNodeReleases from '../src/releases.mjs';
 
-const availableGenerators = Object.keys(publicGenerators);
+import {
+  intro,
+  outro,
+  select,
+  multiselect,
+  text,
+  confirm,
+  isCancel,
+  cancel,
+} from '@clack/prompts';
 
-const program = new Command();
+// Derive available options dynamically from imported modules
+const availableGenerators = Object.keys(publicGenerators); // e.g. ['html', 'json']
+const availableRules = Object.keys(rules); // Linter rule names
+const availableReporters = Object.keys(reporters); // Reporter implementations
 
+// Initialize Commander.js
+const program = new Command();
 program
   .name('api-docs-tooling')
-  .description('CLI tool to generate API documentation of a Node.js project.')
-  .addOption(
-    new Option(
-      '-i, --input [patterns...]',
-      'Specify input file patterns using glob syntax'
-    ).makeOptionMandatory()
-  )
-  .addOption(
-    new Option(
-      '--ignore [patterns...]',
-      'Specify which input files to ignore using glob syntax'
-    )
-  )
-  .addOption(
-    new Option(
-      '-o, --output <path>',
-      'Specify the relative or absolute output directory'
-    )
-  )
-  .addOption(
-    new Option(
-      '-v, --version <semver>',
-      'Specify the target version of Node.js, semver compliant'
-    ).default(DOC_NODE_VERSION)
-  )
-  .addOption(
-    new Option(
-      '-c, --changelog <url>',
-      'Specify the path (file: or https://) to the CHANGELOG.md file'
-    ).default(DOC_NODE_CHANGELOG_URL)
-  )
-  .addOption(
-    new Option(
-      '-t, --target [mode...]',
-      'Set the processing target modes'
-    ).choices(availableGenerators)
-  )
-  .addOption(
-    new Option('--disable-rule [rule...]', 'Disable a specific linter rule')
-      .choices(Object.keys(rules))
-      .default([])
-  )
-  .addOption(
-    new Option('--lint-dry-run', 'Run linter in dry-run mode').default(false)
-  )
-  .addOption(
-    new Option('--git-ref', 'A git ref/commit URL pointing to Node.js').default(
-      'https://github.com/nodejs/node/tree/HEAD'
-    )
-  )
-  .addOption(
-    new Option('-r, --reporter [reporter]', 'Specify the linter reporter')
-      .choices(Object.keys(reporters))
-      .default('console')
-  )
-  .addOption(
-    new Option(
-      '-p, --threads <number>',
-      'The maximum number of threads to use. Set to 1 to disable parallelism'
-    ).default(Math.max(1, cpus().length - 1))
-  )
-  .parse(process.argv);
+  .description('CLI tool to generate and lint Node.js API documentation');
+
+// Instantiate loader and parser once to reuse
+const loader = createMarkdownLoader();
+const parser = createMarkdownParser();
 
 /**
- * @typedef {keyof publicGenerators} Target A list of the available generator names.
- *
- * @typedef {Object} Options
- * @property {Array<string>|string} input Specifies the glob/path for input files.
- * @property {string} output Specifies the directory where output files will be saved.
- * @property {Target[]} target Specifies the generator target mode.
- * @property {string} version Specifies the target Node.js version.
- * @property {string} changelog Specifies the path to the Node.js CHANGELOG.md file.
- * @property {string[]} disableRule Specifies the linter rules to disable.
- * @property {boolean} lintDryRun Specifies whether the linter should run in dry-run mode.
- * @property {boolean} useGit Specifies whether the parser should execute optional git commands. (Should only be used within a git repo)
- * @property {keyof reporters} reporter Specifies the linter reporter.
- *
- * @name ProgramOptions
- * @type {Options}
- * @description The return type for values sent to the program from the CLI.
+ * Load and parse markdown API docs.
+ * @param {string[]} input - Glob patterns for input files.
+ * @param {string[]} [ignore] - Glob patterns to ignore.
+ * @returns {Promise<ApiDocMetadataEntry[]>} - Parsed documentation objects.
  */
-const {
-  input,
-  ignore,
-  output,
-  target = [],
-  version,
-  changelog,
-  disableRule,
-  lintDryRun,
-  gitRef,
-  reporter,
-  threads,
-} = program.opts();
-
-const linter = createLinter(lintDryRun, disableRule);
-
-const { loadFiles } = createMarkdownLoader();
-const { parseApiDocs } = createMarkdownParser();
-
-const apiDocFiles = await loadFiles(input, ignore);
-
-const parsedApiDocs = await parseApiDocs(apiDocFiles);
-
-const { runGenerators } = createGenerator(parsedApiDocs);
-
-// Retrieves Node.js release metadata from a given Node.js version and CHANGELOG.md file
-const { getAllMajors } = createNodeReleases(changelog);
-
-// Runs the Linter on the parsed API docs
-linter.lintAll(parsedApiDocs);
-
-if (target) {
-  await runGenerators({
-    // A list of target modes for the API docs parser
-    generators: target,
-    // Resolved `input` to be used
-    input: input,
-    // Resolved `output` path to be used
-    output: output && resolve(output),
-    // Resolved SemVer of current Node.js version
-    version: coerce(version),
-    // A list of all Node.js major versions with LTS status
-    releases: await getAllMajors(),
-    // An URL containing a git ref URL pointing to the commit or ref that was used
-    // to generate the API docs. This is used to link to the source code of the
-    gitRef,
-    // How many threads should be used
-    threads,
-  });
+async function loadAndParse(input, ignore) {
+  const files = await loader.loadFiles(input, ignore);
+  return parser.parseApiDocs(files);
+}
+
+/**
+ * Run the linter on parsed documentation.
+ * @param {ApiDocMetadataEntry[]} docs - Parsed documentation objects.
+ * @param {object} [opts]
+ * @param {string[]} [opts.disableRule] - List of rule names to disable.
+ * @param {boolean} [opts.lintDryRun] - If true, do not throw on errors.
+ * @param {string} [opts.reporter] - Reporter to use for output.
+ * @returns {boolean} - True if no errors, false otherwise.
+ */
+function runLint(
+  docs,
+  { disableRule = [], lintDryRun = false, reporter = 'console' } = {}
+) {
+  const linter = createLinter(lintDryRun, disableRule);
+  linter.lintAll(docs);
+  linter.report(reporter);
+  return !linter.hasError();
+}
+
+/**
+ * Require value to have a length > 0
+ * @param {string} value
+ * @returns {boolean}
+ */
+function requireValue(value) {
+  if (value.length === 0) return 'Value is required!';
+}
+
+/**
+ * Get the message for a prompt
+ * @param {{ message: string, required: boolean }} prompt
+ * @returns {string}
+ */
+function getMessage({ message, required, initialValue }) {
+  return required || initialValue ? message : `${message} (Optional)`;
 }
 
-// Reports Lint Content
-linter.report(reporter);
+/**
+ * Centralized command definitions.
+ * Each command has a description and a set of options with:
+ * - flags: Commander.js flag definitions
+ * - desc: description for help output
+ * - prompt: metadata for interactive mode
+ */
+const commandDefinitions = {
+  generate: {
+    description: 'Generate API docs',
+    options: {
+      input: {
+        flags: ['-i, --input <patterns...>'],
+        desc: 'Input file patterns (glob)',
+        prompt: {
+          type: 'text',
+          message: 'Enter input glob patterns',
+          variadic: true,
+          required: true,
+        },
+      },
+      ignore: {
+        flags: ['--ignore [patterns...]'],
+        desc: 'Ignore patterns (comma-separated)',
+        prompt: {
+          type: 'text',
+          message: 'Enter ignore patterns',
+          variadic: true,
+        },
+      },
+      output: {
+        flags: ['-o, --output <dir>'],
+        desc: 'Output directory',
+        prompt: { type: 'text', message: 'Enter output directory' },
+      },
+      threads: {
+        flags: ['-p, --threads <number>'],
+        prompt: {
+          type: 'text',
+          message: 'How many threads to allow',
+          initialValue: String(Math.max(cpus().length, 1)),
+        },
+      },
+      version: {
+        flags: ['-v, --version <semver>'],
+        desc: 'Target Node.js version',
+        prompt: {
+          type: 'text',
+          message: 'Enter Node.js version',
+          initialValue: DOC_NODE_VERSION,
+        },
+      },
+      changelog: {
+        flags: ['-c, --changelog <url>'],
+        desc: 'Changelog URL or path',
+        prompt: {
+          type: 'text',
+          message: 'Enter changelog URL',
+          initialValue: DOC_NODE_CHANGELOG_URL,
+        },
+      },
+      gitRef: {
+        flags: ['--git-ref <url>'],
+        desc: 'Git ref/commit URL',
+        prompt: {
+          type: 'text',
+          message: 'Enter Git ref URL',
+          initialValue: 'https://github.com/nodejs/node/tree/HEAD',
+        },
+      },
+      target: {
+        flags: ['-t, --target [modes...]'],
+        desc: 'Target generator modes',
+        prompt: {
+          required: true,
+          type: 'multiselect',
+          message: 'Choose target generators',
+          options: availableGenerators.map(g => ({
+            label: g,
+            value: `${publicGenerators[g].name || g} (v${publicGenerators[g].version}) - ${publicGenerators[g].description}`,
+          })),
+        },
+      },
+      skipLint: {
+        flags: ['--no-lint'],
+        desc: 'Skip lint before generate',
+        prompt: {
+          type: 'confirm',
+          message: 'Skip lint before generate?',
+          initialValue: false,
+        },
+      },
+    },
+  },
+  lint: {
+    description: 'Run linter independently',
+    options: {
+      input: {
+        flags: ['-i, --input <patterns...>'],
+        desc: 'Input file patterns (glob)',
+        prompt: {
+          type: 'text',
+          message: 'Enter input glob patterns',
+          variadic: true,
+          required: true,
+        },
+      },
+      ignore: {
+        flags: ['--ignore [patterns...]'],
+        desc: 'Ignore patterns (comma-separated)',
+        prompt: {
+          type: 'text',
+          message: 'Enter ignore patterns',
+          variadic: true,
+        },
+      },
+      disableRule: {
+        flags: ['--disable-rule [rules...]'],
+        desc: 'Disable linter rules',
+        prompt: {
+          type: 'multiselect',
+          message: 'Choose rules to disable',
+          options: availableRules.map(r => ({ label: r, value: r })),
+        },
+      },
+      lintDryRun: {
+        flags: ['--lint-dry-run'],
+        desc: 'Dry run lint mode',
+        prompt: {
+          type: 'confirm',
+          message: 'Enable dry run mode?',
+          initialValue: false,
+        },
+      },
+      reporter: {
+        flags: ['-r, --reporter <reporter>'],
+        desc: 'Linter reporter to use',
+        prompt: {
+          type: 'select',
+          message: 'Choose a reporter',
+          options: availableReporters.map(r => ({ label: r, value: r })),
+        },
+      },
+    },
+  },
+};
+
+// Dynamically register commands based on definitions
+Object.entries(commandDefinitions).forEach(
+  ([cmdName, { description, options }]) => {
+    // Create a new command in Commander
+    const cmd = program.command(cmdName).description(description);
+
+    // Register each option
+    Object.values(options).forEach(({ flags, desc, prompt }) => {
+      const option = new Option(flags.join(', '), desc);
+      option.default(prompt.initialValue);
+      if (prompt.required) option.makeOptionMandatory();
+      if (prompt.type === 'multiselect')
+        option.choices(prompt.options.map(({ label }) => label));
+      cmd.addOption(option);
+    });
+
+    // Define the command's action handler
+    cmd.action(async opts => {
+      // Parse docs from markdown
+      const docs = await loadAndParse(opts.input, opts.ignore);
+
+      if (cmdName === 'generate') {
+        // Pre-lint step (skip if requested)
+        if (!opts.skipLint && !runLint(docs)) {
+          console.error('Lint failed; aborting generation.');
+          process.exit(1);
+        }
+
+        // Generate API docs via configured generators
+        const { runGenerators } = createGenerator(docs);
+        const { getAllMajors } = createNodeReleases(opts.changelog);
+        await runGenerators({
+          generators: opts.target,
+          input: opts.input,
+          output: opts.output && resolve(opts.output),
+          version: coerce(opts.version),
+          releases: await getAllMajors(),
+          gitRef: opts.gitRef,
+          threads: parseInt(opts.threads),
+        });
+      } else {
+        // Lint-only mode
+        const success = runLint(docs, {
+          disableRule: opts.disableRule,
+          lintDryRun: opts.lintDryRun,
+          reporter: opts.reporter,
+        });
+        process.exitCode = success ? 0 : 1;
+      }
+    });
+  }
+);
+
+// Add list subcommands to inspect available modules
+program
+  .command('list')
+  .addArgument(
+    new Argument('<type>', 'Type to list').choices([
+      'generators',
+      'rules',
+      'reporters',
+    ])
+  )
+  .description('List available types')
+  .action(type => {
+    const list =
+      type === 'generators'
+        ? Object.entries(publicGenerators).map(
+            ([key, generator]) =>
+              `${generator.name || key} (v${generator.version}) - ${generator.description}`
+          )
+        : type === 'rules'
+          ? availableRules
+          : availableReporters;
+
+    console.log(list.join('\n'));
+  });
+
+// Interactive mode: guides the user through building a command
+program
+  .command('interactive')
+  .description('Launch guided CLI wizard')
+  .action(async () => {
+    intro('Welcome to API Docs Tooling');
+
+    // Build action choices from definitions
+    const actionOptions = Object.entries(commandDefinitions).map(
+      ([name, def]) => ({
+        label: def.description,
+        value: name,
+      })
+    );
+
+    // Prompt user to choose a command
+    const action = await select({
+      message: 'What would you like to do?',
+      options: actionOptions,
+    });
+
+    if (isCancel(action)) {
+      cancel('Cancelled.');
+      process.exit(0);
+    }
+
+    const { options } = commandDefinitions[action];
+    const answers = {};
+
+    // Iterate through each option's prompt metadata
+    for (const [key, { prompt }] of Object.entries(options)) {
+      let response;
+      switch (prompt.type) {
+        case 'text':
+          response = await text({
+            message: getMessage(prompt),
+            initialValue: prompt.initialValue || '',
+            validate: prompt.required ? requireValue : undefined,
+          });
+          if (response) {
+            answers[key] = prompt.variadic ? response.split(',') : response;
+          }
+          break;
+        case 'confirm':
+          response = await confirm({
+            message: getMessage(prompt),
+            initialValue: prompt.initialValue,
+          });
+          answers[key] = response;
+          break;
+        case 'multiselect':
+          response = await multiselect({
+            message: getMessage(prompt),
+            options: prompt.options,
+            required: !!prompt.required,
+          });
+          answers[key] = response;
+          break;
+        case 'select':
+          response = await select({
+            message: getMessage(prompt),
+            options: prompt.options,
+          });
+          answers[key] = response;
+          break;
+      }
+
+      if (isCancel(response)) {
+        cancel('Cancelled.');
+        process.exit(0);
+      }
+    }
+
+    // Build the final CLI command string
+    let cmdStr = `npx api-docs-tooling ${action}`;
+    for (const [key, { flags }] of Object.entries(options)) {
+      const val = answers[key];
+      if (val == null || (Array.isArray(val) && val.length === 0)) continue;
+      const flag = flags[0].split(/[\s,]+/)[0];
+      if (typeof val === 'boolean') {
+        if (val) cmdStr += ` ${flag}`;
+      } else if (Array.isArray(val)) {
+        cmdStr += ` ${flag} ${val.join(',')}`;
+      } else {
+        cmdStr += ` ${flag} ${val}`;
+      }
+    }
+
+    // Display and optionally run the constructed command
+    console.log(`\nGenerated command:\n${cmdStr}\n`);
+    if (await confirm({ message: 'Run now?', initialValue: true })) {
+      const args = cmdStr.split(' ').slice(2);
+      spawnSync(process.execPath, [process.argv[1], ...args], {
+        stdio: 'inherit',
+      });
+    }
+
+    outro('Done!');
+  });
+
+// Help and version commands for user assistance
+program
+  .command('help [cmd]')
+  .description('Show help for a command')
+  .action(cmdName => {
+    const target = program.commands.find(c => c.name() === cmdName) || program;
+    target.help();
+  });
 
-process.exitCode = Number(linter.hasError());
+// Parse CLI arguments and execute
+program.parse(process.argv);
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 3eb1bc7a..70bc0027 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -6,7 +6,7 @@ import globals from 'globals';
 export default [
   // @see https://eslint.org/docs/latest/use/configure/configuration-files#specifying-files-and-ignores
   {
-    files: ['src/**/*.mjs'],
+    files: ['src/**/*.mjs', 'bin/cli.mjs'],
     plugins: {
       jsdoc: jsdoc,
     },
diff --git a/package-lock.json b/package-lock.json
index d9ff816c..d8b6e8b4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,6 +7,7 @@
       "name": "@node-core/api-docs-tooling",
       "dependencies": {
         "@actions/core": "^1.11.1",
+        "@clack/prompts": "^0.10.1",
         "@orama/orama": "^3.1.3",
         "@orama/plugin-data-persistence": "^3.1.3",
         "acorn": "^8.14.1",
@@ -86,6 +87,27 @@
       "integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==",
       "license": "MIT"
     },
+    "node_modules/@clack/core": {
+      "version": "0.4.2",
+      "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.4.2.tgz",
+      "integrity": "sha512-NYQfcEy8MWIxrT5Fj8nIVchfRFA26yYKJcvBS7WlUIlw2OmQOY9DhGGXMovyI5J5PpxrCPGkgUi207EBrjpBvg==",
+      "license": "MIT",
+      "dependencies": {
+        "picocolors": "^1.0.0",
+        "sisteransi": "^1.0.5"
+      }
+    },
+    "node_modules/@clack/prompts": {
+      "version": "0.10.1",
+      "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.10.1.tgz",
+      "integrity": "sha512-Q0T02vx8ZM9XSv9/Yde0jTmmBQufZhPJfYAg2XrrrxWWaZgq1rr8nU8Hv710BQ1dhoP8rtY7YUdpGej2Qza/cw==",
+      "license": "MIT",
+      "dependencies": {
+        "@clack/core": "0.4.2",
+        "picocolors": "^1.0.0",
+        "sisteransi": "^1.0.5"
+      }
+    },
     "node_modules/@es-joy/jsdoccomment": {
       "version": "0.49.0",
       "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.49.0.tgz",
@@ -3285,6 +3307,12 @@
         "url": "https://github.com/sponsors/isaacs"
       }
     },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "license": "ISC"
+    },
     "node_modules/picomatch": {
       "version": "2.3.1",
       "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
@@ -3577,6 +3605,12 @@
         "url": "https://github.com/sponsors/isaacs"
       }
     },
+    "node_modules/sisteransi": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
+      "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
+      "license": "MIT"
+    },
     "node_modules/slashes": {
       "version": "3.0.12",
       "resolved": "https://registry.npmjs.org/slashes/-/slashes-3.0.12.tgz",
diff --git a/package.json b/package.json
index 7affb1ba..fda68b77 100644
--- a/package.json
+++ b/package.json
@@ -35,6 +35,7 @@
   },
   "dependencies": {
     "@actions/core": "^1.11.1",
+    "@clack/prompts": "^0.10.1",
     "@orama/orama": "^3.1.3",
     "@orama/plugin-data-persistence": "^3.1.3",
     "acorn": "^8.14.1",

From 0255fb6d3e236365c0dfa6acea36f09928a6bfeb Mon Sep 17 00:00:00 2001
From: avivkeller <me@aviv.sh>
Date: Wed, 16 Apr 2025 12:52:09 -0400
Subject: [PATCH 2/7] split into multiple files

---
 README.md                                     |  14 +-
 bin/cli.mjs                                   | 436 ++----------------
 bin/commands/generate.mjs                     | 146 ++++++
 bin/commands/index.mjs                        |   4 +
 bin/commands/interactive.mjs                  | 172 +++++++
 bin/commands/lint.mjs                         | 106 +++++
 bin/commands/list.mjs                         |  26 ++
 bin/utils.mjs                                 |  40 ++
 eslint.config.mjs                             |   2 +-
 .../legacy-html/utils/buildContent.mjs        |  10 +-
 10 files changed, 538 insertions(+), 418 deletions(-)
 create mode 100644 bin/commands/generate.mjs
 create mode 100644 bin/commands/index.mjs
 create mode 100644 bin/commands/interactive.mjs
 create mode 100644 bin/commands/lint.mjs
 create mode 100644 bin/commands/list.mjs
 create mode 100644 bin/utils.mjs

diff --git a/README.md b/README.md
index 002742d5..b9979dea 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,12 @@
 
 ## Usage
 
+### `help`
+
+```sh
+npx api-docs-tooling help [command]
+```
+
 ### `generate`
 
 Generate API documentation from Markdown files.
@@ -55,7 +61,7 @@ npx api-docs-tooling lint [options]
 - `-i, --input <patterns...>` Input file patterns (glob)
 - `--ignore [patterns...]` Files to ignore
 - `--disable-rule [rules...]` Disable specific linting rules
-- `--lint-dry-run` Run linter without applying changes
+- `--dry-run` Run linter without applying changes
 - `-r, --reporter <reporter>` Reporter format: `console`, `github`, etc.
 
 ### `interactive`
@@ -75,9 +81,3 @@ npx api-docs-tooling list generators
 npx api-docs-tooling list rules
 npx api-docs-tooling list reporters
 ```
-
-### `help`
-
-```sh
-npx api-docs-tooling help [command]
-```
diff --git a/bin/cli.mjs b/bin/cli.mjs
index a679d107..748abb77 100755
--- a/bin/cli.mjs
+++ b/bin/cli.mjs
@@ -1,430 +1,62 @@
 #!/usr/bin/env node
 
-import { resolve } from 'node:path';
 import process from 'node:process';
-import { spawnSync } from 'node:child_process';
-import { cpus } from 'node:os';
-
 import { Argument, Command, Option } from 'commander';
 
-import { coerce } from 'semver';
-import { DOC_NODE_CHANGELOG_URL, DOC_NODE_VERSION } from '../src/constants.mjs';
-import createGenerator from '../src/generators.mjs';
-import { publicGenerators } from '../src/generators/index.mjs';
-import createLinter from '../src/linter/index.mjs';
-import reporters from '../src/linter/reporters/index.mjs';
-import rules from '../src/linter/rules/index.mjs';
-import createMarkdownLoader from '../src/loaders/markdown.mjs';
-import createMarkdownParser from '../src/parsers/markdown.mjs';
-import createNodeReleases from '../src/releases.mjs';
-
-import {
-  intro,
-  outro,
-  select,
-  multiselect,
-  text,
-  confirm,
-  isCancel,
-  cancel,
-} from '@clack/prompts';
-
-// Derive available options dynamically from imported modules
-const availableGenerators = Object.keys(publicGenerators); // e.g. ['html', 'json']
-const availableRules = Object.keys(rules); // Linter rule names
-const availableReporters = Object.keys(reporters); // Reporter implementations
+import interactive from './commands/interactive.mjs';
+import list, { types } from './commands/list.mjs';
+import commands from './commands/index.mjs';
 
-// Initialize Commander.js
-const program = new Command();
-program
+const program = new Command()
   .name('api-docs-tooling')
   .description('CLI tool to generate and lint Node.js API documentation');
 
-// Instantiate loader and parser once to reuse
-const loader = createMarkdownLoader();
-const parser = createMarkdownParser();
-
-/**
- * Load and parse markdown API docs.
- * @param {string[]} input - Glob patterns for input files.
- * @param {string[]} [ignore] - Glob patterns to ignore.
- * @returns {Promise<ApiDocMetadataEntry[]>} - Parsed documentation objects.
- */
-async function loadAndParse(input, ignore) {
-  const files = await loader.loadFiles(input, ignore);
-  return parser.parseApiDocs(files);
-}
-
-/**
- * Run the linter on parsed documentation.
- * @param {ApiDocMetadataEntry[]} docs - Parsed documentation objects.
- * @param {object} [opts]
- * @param {string[]} [opts.disableRule] - List of rule names to disable.
- * @param {boolean} [opts.lintDryRun] - If true, do not throw on errors.
- * @param {string} [opts.reporter] - Reporter to use for output.
- * @returns {boolean} - True if no errors, false otherwise.
- */
-function runLint(
-  docs,
-  { disableRule = [], lintDryRun = false, reporter = 'console' } = {}
-) {
-  const linter = createLinter(lintDryRun, disableRule);
-  linter.lintAll(docs);
-  linter.report(reporter);
-  return !linter.hasError();
-}
-
-/**
- * Require value to have a length > 0
- * @param {string} value
- * @returns {boolean}
- */
-function requireValue(value) {
-  if (value.length === 0) return 'Value is required!';
-}
-
-/**
- * Get the message for a prompt
- * @param {{ message: string, required: boolean }} prompt
- * @returns {string}
- */
-function getMessage({ message, required, initialValue }) {
-  return required || initialValue ? message : `${message} (Optional)`;
-}
-
-/**
- * Centralized command definitions.
- * Each command has a description and a set of options with:
- * - flags: Commander.js flag definitions
- * - desc: description for help output
- * - prompt: metadata for interactive mode
- */
-const commandDefinitions = {
-  generate: {
-    description: 'Generate API docs',
-    options: {
-      input: {
-        flags: ['-i, --input <patterns...>'],
-        desc: 'Input file patterns (glob)',
-        prompt: {
-          type: 'text',
-          message: 'Enter input glob patterns',
-          variadic: true,
-          required: true,
-        },
-      },
-      ignore: {
-        flags: ['--ignore [patterns...]'],
-        desc: 'Ignore patterns (comma-separated)',
-        prompt: {
-          type: 'text',
-          message: 'Enter ignore patterns',
-          variadic: true,
-        },
-      },
-      output: {
-        flags: ['-o, --output <dir>'],
-        desc: 'Output directory',
-        prompt: { type: 'text', message: 'Enter output directory' },
-      },
-      threads: {
-        flags: ['-p, --threads <number>'],
-        prompt: {
-          type: 'text',
-          message: 'How many threads to allow',
-          initialValue: String(Math.max(cpus().length, 1)),
-        },
-      },
-      version: {
-        flags: ['-v, --version <semver>'],
-        desc: 'Target Node.js version',
-        prompt: {
-          type: 'text',
-          message: 'Enter Node.js version',
-          initialValue: DOC_NODE_VERSION,
-        },
-      },
-      changelog: {
-        flags: ['-c, --changelog <url>'],
-        desc: 'Changelog URL or path',
-        prompt: {
-          type: 'text',
-          message: 'Enter changelog URL',
-          initialValue: DOC_NODE_CHANGELOG_URL,
-        },
-      },
-      gitRef: {
-        flags: ['--git-ref <url>'],
-        desc: 'Git ref/commit URL',
-        prompt: {
-          type: 'text',
-          message: 'Enter Git ref URL',
-          initialValue: 'https://github.com/nodejs/node/tree/HEAD',
-        },
-      },
-      target: {
-        flags: ['-t, --target [modes...]'],
-        desc: 'Target generator modes',
-        prompt: {
-          required: true,
-          type: 'multiselect',
-          message: 'Choose target generators',
-          options: availableGenerators.map(g => ({
-            label: g,
-            value: `${publicGenerators[g].name || g} (v${publicGenerators[g].version}) - ${publicGenerators[g].description}`,
-          })),
-        },
-      },
-      skipLint: {
-        flags: ['--no-lint'],
-        desc: 'Skip lint before generate',
-        prompt: {
-          type: 'confirm',
-          message: 'Skip lint before generate?',
-          initialValue: false,
-        },
-      },
-    },
-  },
-  lint: {
-    description: 'Run linter independently',
-    options: {
-      input: {
-        flags: ['-i, --input <patterns...>'],
-        desc: 'Input file patterns (glob)',
-        prompt: {
-          type: 'text',
-          message: 'Enter input glob patterns',
-          variadic: true,
-          required: true,
-        },
-      },
-      ignore: {
-        flags: ['--ignore [patterns...]'],
-        desc: 'Ignore patterns (comma-separated)',
-        prompt: {
-          type: 'text',
-          message: 'Enter ignore patterns',
-          variadic: true,
-        },
-      },
-      disableRule: {
-        flags: ['--disable-rule [rules...]'],
-        desc: 'Disable linter rules',
-        prompt: {
-          type: 'multiselect',
-          message: 'Choose rules to disable',
-          options: availableRules.map(r => ({ label: r, value: r })),
-        },
-      },
-      lintDryRun: {
-        flags: ['--lint-dry-run'],
-        desc: 'Dry run lint mode',
-        prompt: {
-          type: 'confirm',
-          message: 'Enable dry run mode?',
-          initialValue: false,
-        },
-      },
-      reporter: {
-        flags: ['-r, --reporter <reporter>'],
-        desc: 'Linter reporter to use',
-        prompt: {
-          type: 'select',
-          message: 'Choose a reporter',
-          options: availableReporters.map(r => ({ label: r, value: r })),
-        },
-      },
-    },
-  },
-};
+// Registering generate and lint commands
+commands.forEach(({ name, description, options, action }) => {
+  const cmd = program.command(name).description(description);
 
-// Dynamically register commands based on definitions
-Object.entries(commandDefinitions).forEach(
-  ([cmdName, { description, options }]) => {
-    // Create a new command in Commander
-    const cmd = program.command(cmdName).description(description);
-
-    // Register each option
-    Object.values(options).forEach(({ flags, desc, prompt }) => {
-      const option = new Option(flags.join(', '), desc);
-      option.default(prompt.initialValue);
-      if (prompt.required) option.makeOptionMandatory();
-      if (prompt.type === 'multiselect')
-        option.choices(prompt.options.map(({ label }) => label));
-      cmd.addOption(option);
-    });
-
-    // Define the command's action handler
-    cmd.action(async opts => {
-      // Parse docs from markdown
-      const docs = await loadAndParse(opts.input, opts.ignore);
-
-      if (cmdName === 'generate') {
-        // Pre-lint step (skip if requested)
-        if (!opts.skipLint && !runLint(docs)) {
-          console.error('Lint failed; aborting generation.');
-          process.exit(1);
-        }
+  // Add options to the command
+  Object.values(options).forEach(({ flags, desc, prompt }) => {
+    const option = new Option(flags.join(', '), desc).default(
+      prompt.initialValue
+    );
 
-        // Generate API docs via configured generators
-        const { runGenerators } = createGenerator(docs);
-        const { getAllMajors } = createNodeReleases(opts.changelog);
-        await runGenerators({
-          generators: opts.target,
-          input: opts.input,
-          output: opts.output && resolve(opts.output),
-          version: coerce(opts.version),
-          releases: await getAllMajors(),
-          gitRef: opts.gitRef,
-          threads: parseInt(opts.threads),
-        });
-      } else {
-        // Lint-only mode
-        const success = runLint(docs, {
-          disableRule: opts.disableRule,
-          lintDryRun: opts.lintDryRun,
-          reporter: opts.reporter,
-        });
-        process.exitCode = success ? 0 : 1;
-      }
-    });
-  }
-);
+    if (prompt.required) {
+      option.makeOptionMandatory();
+    }
 
-// Add list subcommands to inspect available modules
-program
-  .command('list')
-  .addArgument(
-    new Argument('<type>', 'Type to list').choices([
-      'generators',
-      'rules',
-      'reporters',
-    ])
-  )
-  .description('List available types')
-  .action(type => {
-    const list =
-      type === 'generators'
-        ? Object.entries(publicGenerators).map(
-            ([key, generator]) =>
-              `${generator.name || key} (v${generator.version}) - ${generator.description}`
-          )
-        : type === 'rules'
-          ? availableRules
-          : availableReporters;
+    if (prompt.type === 'multiselect') {
+      option.choices(prompt.options.map(({ value }) => value));
+    }
 
-    console.log(list.join('\n'));
+    cmd.addOption(option);
   });
 
-// Interactive mode: guides the user through building a command
+  // Set the action for the command
+  cmd.action(action);
+});
+
+// Register the interactive command
 program
   .command('interactive')
   .description('Launch guided CLI wizard')
-  .action(async () => {
-    intro('Welcome to API Docs Tooling');
-
-    // Build action choices from definitions
-    const actionOptions = Object.entries(commandDefinitions).map(
-      ([name, def]) => ({
-        label: def.description,
-        value: name,
-      })
-    );
+  .action(interactive);
 
-    // Prompt user to choose a command
-    const action = await select({
-      message: 'What would you like to do?',
-      options: actionOptions,
-    });
-
-    if (isCancel(action)) {
-      cancel('Cancelled.');
-      process.exit(0);
-    }
-
-    const { options } = commandDefinitions[action];
-    const answers = {};
-
-    // Iterate through each option's prompt metadata
-    for (const [key, { prompt }] of Object.entries(options)) {
-      let response;
-      switch (prompt.type) {
-        case 'text':
-          response = await text({
-            message: getMessage(prompt),
-            initialValue: prompt.initialValue || '',
-            validate: prompt.required ? requireValue : undefined,
-          });
-          if (response) {
-            answers[key] = prompt.variadic ? response.split(',') : response;
-          }
-          break;
-        case 'confirm':
-          response = await confirm({
-            message: getMessage(prompt),
-            initialValue: prompt.initialValue,
-          });
-          answers[key] = response;
-          break;
-        case 'multiselect':
-          response = await multiselect({
-            message: getMessage(prompt),
-            options: prompt.options,
-            required: !!prompt.required,
-          });
-          answers[key] = response;
-          break;
-        case 'select':
-          response = await select({
-            message: getMessage(prompt),
-            options: prompt.options,
-          });
-          answers[key] = response;
-          break;
-      }
-
-      if (isCancel(response)) {
-        cancel('Cancelled.');
-        process.exit(0);
-      }
-    }
-
-    // Build the final CLI command string
-    let cmdStr = `npx api-docs-tooling ${action}`;
-    for (const [key, { flags }] of Object.entries(options)) {
-      const val = answers[key];
-      if (val == null || (Array.isArray(val) && val.length === 0)) continue;
-      const flag = flags[0].split(/[\s,]+/)[0];
-      if (typeof val === 'boolean') {
-        if (val) cmdStr += ` ${flag}`;
-      } else if (Array.isArray(val)) {
-        cmdStr += ` ${flag} ${val.join(',')}`;
-      } else {
-        cmdStr += ` ${flag} ${val}`;
-      }
-    }
-
-    // Display and optionally run the constructed command
-    console.log(`\nGenerated command:\n${cmdStr}\n`);
-    if (await confirm({ message: 'Run now?', initialValue: true })) {
-      const args = cmdStr.split(' ').slice(2);
-      spawnSync(process.execPath, [process.argv[1], ...args], {
-        stdio: 'inherit',
-      });
-    }
-
-    outro('Done!');
-  });
+// Register the list command
+program
+  .command('list')
+  .addArgument(new Argument('<types>', 'The type to list').choices(types))
+  .description('List the given type')
+  .action(list);
 
-// Help and version commands for user assistance
+// Register the help command
 program
   .command('help [cmd]')
   .description('Show help for a command')
   .action(cmdName => {
-    const target = program.commands.find(c => c.name() === cmdName) || program;
+    const target = program.commands.find(c => c.name() === cmdName) ?? program;
     target.help();
   });
 
-// Parse CLI arguments and execute
+// Parse and execute command-line arguments
 program.parse(process.argv);
diff --git a/bin/commands/generate.mjs b/bin/commands/generate.mjs
new file mode 100644
index 00000000..6911b7df
--- /dev/null
+++ b/bin/commands/generate.mjs
@@ -0,0 +1,146 @@
+import { cpus } from 'node:os';
+import { resolve } from 'node:path';
+import process from 'node:process';
+
+import { coerce } from 'semver';
+
+import {
+  DOC_NODE_CHANGELOG_URL,
+  DOC_NODE_VERSION,
+} from '../../src/constants.mjs';
+import createGenerator from '../../src/generators.mjs';
+import { publicGenerators } from '../../src/generators/index.mjs';
+import createNodeReleases from '../../src/releases.mjs';
+import { loadAndParse } from '../utils.mjs';
+import { runLint } from './lint.mjs';
+
+const availableGenerators = Object.keys(publicGenerators);
+
+/**
+ * @typedef {Object} Options
+ * @property {Array<string>|string} input - Specifies the glob/path for input files.
+ * @property {Array<string>|string} [ignore] - Specifies the glob/path for ignoring files.
+ * @property {Array<keyof publicGenerators>} target - Specifies the generator target mode.
+ * @property {string} version - Specifies the target Node.js version.
+ * @property {string} changelog - Specifies the path to the Node.js CHANGELOG.md file.
+ * @property {string} [gitRef] - Git ref/commit URL.
+ * @property {number} [threads] - Number of threads to allow.
+ * @property {boolean} [skipLint] - Skip lint before generate.
+ */
+
+/**
+ * @type {import('../utils.mjs').Command}
+ */
+export default {
+  description: 'Generate API docs',
+  name: 'generate',
+  options: {
+    input: {
+      flags: ['-i', '--input <patterns...>'],
+      desc: 'Input file patterns (glob)',
+      prompt: {
+        type: 'text',
+        message: 'Enter input glob patterns',
+        variadic: true,
+        required: true,
+      },
+    },
+    ignore: {
+      flags: ['--ignore [patterns...]'],
+      desc: 'Ignore patterns (comma-separated)',
+      prompt: {
+        type: 'text',
+        message: 'Enter ignore patterns',
+        variadic: true,
+      },
+    },
+    output: {
+      flags: ['-o', '--output <dir>'],
+      desc: 'Output directory',
+      prompt: { type: 'text', message: 'Enter output directory' },
+    },
+    threads: {
+      flags: ['-p', '--threads <number>'],
+      prompt: {
+        type: 'text',
+        message: 'How many threads to allow',
+        initialValue: String(Math.max(cpus().length, 1)),
+      },
+    },
+    version: {
+      flags: ['-v', '--version <semver>'],
+      desc: 'Target Node.js version',
+      prompt: {
+        type: 'text',
+        message: 'Enter Node.js version',
+        initialValue: DOC_NODE_VERSION,
+      },
+    },
+    changelog: {
+      flags: ['-c', '--changelog <url>'],
+      desc: 'Changelog URL or path',
+      prompt: {
+        type: 'text',
+        message: 'Enter changelog URL',
+        initialValue: DOC_NODE_CHANGELOG_URL,
+      },
+    },
+    gitRef: {
+      flags: ['--git-ref <url>'],
+      desc: 'Git ref/commit URL',
+      prompt: {
+        type: 'text',
+        message: 'Enter Git ref URL',
+        initialValue: 'https://github.com/nodejs/node/tree/HEAD',
+      },
+    },
+    target: {
+      flags: ['-t', '--target [modes...]'],
+      desc: 'Target generator modes',
+      prompt: {
+        required: true,
+        type: 'multiselect',
+        message: 'Choose target generators',
+        options: availableGenerators.map(g => ({
+          value: g,
+          label: `${publicGenerators[g].name || g} (v${publicGenerators[g].version}) - ${publicGenerators[g].description}`,
+        })),
+      },
+    },
+    skipLint: {
+      flags: ['--no-lint'],
+      desc: 'Skip lint before generate',
+      prompt: {
+        type: 'confirm',
+        message: 'Skip lint before generate?',
+        initialValue: false,
+      },
+    },
+  },
+  /**
+   * Handles the action for generating API docs
+   * @param {Options} opts - The options to generate API docs.
+   * @returns {Promise<void>}
+   */
+  async action(opts) {
+    const docs = await loadAndParse(opts.input, opts.ignore);
+
+    if (!opts.skipLint && !runLint(docs)) {
+      console.error('Lint failed; aborting generation.');
+      process.exit(1);
+    }
+
+    const { runGenerators } = createGenerator(docs);
+    const { getAllMajors } = createNodeReleases(opts.changelog);
+
+    await runGenerators({
+      generators: opts.target,
+      input: opts.input,
+      output: opts.output && resolve(opts.output),
+      version: coerce(opts.version),
+      releases: await getAllMajors(),
+      gitRef: opts.gitRef,
+      threads: parseInt(opts.threads, 10),
+    });
+  },
+};
diff --git a/bin/commands/index.mjs b/bin/commands/index.mjs
new file mode 100644
index 00000000..05a10c93
--- /dev/null
+++ b/bin/commands/index.mjs
@@ -0,0 +1,4 @@
+import generate from './generate.mjs';
+import lint from './lint.mjs';
+
+export default [generate, lint];
diff --git a/bin/commands/interactive.mjs b/bin/commands/interactive.mjs
new file mode 100644
index 00000000..0cdf0884
--- /dev/null
+++ b/bin/commands/interactive.mjs
@@ -0,0 +1,172 @@
+import { spawnSync } from 'node:child_process';
+import process from 'node:process';
+
+import {
+  intro,
+  outro,
+  select,
+  multiselect,
+  text,
+  confirm,
+  isCancel,
+  cancel,
+} from '@clack/prompts';
+
+import commands from './index.mjs';
+
+/**
+ * Validates that a string is not empty.
+ * @param {string} value The input string to validate.
+ * @returns {string|undefined} A validation message or undefined if valid.
+ */
+function requireValue(value) {
+  if (value.length === 0) {
+    return 'Value is required!';
+  }
+}
+
+/**
+ * Retrieves the prompt message based on whether the field is required or has an initial value.
+ * @param {Object} prompt The prompt definition.
+ * @param {string} prompt.message The message to display.
+ * @param {boolean} prompt.required Whether the input is required.
+ * @param {string} [prompt.initialValue] The initial value of the input field.
+ * @returns {string} The message to display in the prompt.
+ */
+function getMessage({ message, required, initialValue }) {
+  return required || initialValue ? message : `${message} (Optional)`;
+}
+
+/**
+ * Escapes shell argument to ensure it's safe for inclusion in shell commands.
+ * @param {string} arg The argument to escape.
+ * @returns {string} The escaped argument.
+ */
+function escapeShellArg(arg) {
+  // Return the argument as is if it's alphanumeric or contains safe characters
+  if (/^[a-zA-Z0-9_/-]+$/.test(arg)) {
+    return arg;
+  }
+  // Escape single quotes in the argument
+  return `'${arg.replace(/'/g, `'\\''`)}'`;
+}
+
+/**
+ * Main interactive function for the API Docs Tooling command line interface.
+ * Guides the user through a series of prompts, validates inputs, and generates a command to run.
+ * @returns {Promise<void>} Resolves once the command is generated and executed.
+ */
+export default async function interactive() {
+  // Step 1: Introduction to the tool
+  intro('Welcome to API Docs Tooling');
+
+  // Step 2: Choose the action based on available command definitions
+  const actionOptions = commands.map(({ description }, i) => ({
+    label: description,
+    value: i,
+  }));
+
+  const selectedAction = await select({
+    message: 'What would you like to do?',
+    options: actionOptions,
+  });
+
+  if (isCancel(selectedAction)) {
+    cancel('Cancelled.');
+    process.exit(0);
+  }
+
+  // Retrieve the options for the selected action
+  const { options, name } = commands[selectedAction];
+  const answers = {}; // Store answers from user prompts
+
+  // Step 3: Collect input for each option
+  for (const [key, { prompt }] of Object.entries(options)) {
+    let response;
+    const promptMessage = getMessage(prompt);
+
+    switch (prompt.type) {
+      case 'text':
+        response = await text({
+          message: promptMessage,
+          initialValue: prompt.initialValue || '',
+          validate: prompt.required ? requireValue : undefined,
+        });
+        if (response) {
+          // Store response; split into an array if variadic
+          answers[key] = prompt.variadic
+            ? response.split(',').map(s => s.trim())
+            : response;
+        }
+        break;
+
+      case 'confirm':
+        response = await confirm({
+          message: promptMessage,
+          initialValue: prompt.initialValue,
+        });
+        answers[key] = response;
+        break;
+
+      case 'multiselect':
+        response = await multiselect({
+          message: promptMessage,
+          options: prompt.options,
+          required: !!prompt.required,
+        });
+        answers[key] = response;
+        break;
+
+      case 'select':
+        response = await select({
+          message: promptMessage,
+          options: prompt.options,
+        });
+        answers[key] = response;
+        break;
+    }
+
+    // Handle cancellation
+    if (isCancel(response)) {
+      cancel('Cancelled.');
+      process.exit(0);
+    }
+  }
+
+  // Step 4: Build the final command by escaping values
+  const cmdParts = ['npx', 'api-docs-tooling', name];
+  const executionArgs = [name];
+
+  for (const [key, { flags }] of Object.entries(options)) {
+    const value = answers[key];
+    if (value == null || (Array.isArray(value) && value.length === 0)) continue; // Skip empty values
+
+    const flag = flags[0].split(/[\s,]+/)[0]; // Use the first flag
+
+    // Handle different value types (boolean, array, string)
+    if (typeof value === 'boolean') {
+      if (value) cmdParts.push(flag);
+    } else if (Array.isArray(value)) {
+      for (const item of value) {
+        cmdParts.push(flag, escapeShellArg(item));
+        executionArgs.push(flag, item);
+      }
+    } else {
+      cmdParts.push(flag, escapeShellArg(value));
+      executionArgs.push(flag, value);
+    }
+  }
+
+  const finalCommand = cmdParts.join(' ');
+
+  console.log(`\nGenerated command:\n${finalCommand}\n`);
+
+  // Step 5: Confirm and execute the generated command
+  if (await confirm({ message: 'Run now?', initialValue: true })) {
+    spawnSync(process.execPath, [process.argv[1], ...executionArgs], {
+      stdio: 'inherit',
+    });
+  }
+
+  outro('Done!');
+}
diff --git a/bin/commands/lint.mjs b/bin/commands/lint.mjs
new file mode 100644
index 00000000..a0abafe2
--- /dev/null
+++ b/bin/commands/lint.mjs
@@ -0,0 +1,106 @@
+import process from 'node:process';
+
+import createLinter from '../../src/linter/index.mjs';
+import reporters from '../../src/linter/reporters/index.mjs';
+import rules from '../../src/linter/rules/index.mjs';
+import { loadAndParse } from '../utils.mjs';
+
+const availableRules = Object.keys(rules);
+const availableReporters = Object.keys(reporters);
+
+/**
+ * @typedef {Object} LinterOptions
+ * @property {Array<string>|string} input - Glob/path for input files.
+ * @property {Array<string>|string} [ignore] - Glob/path for ignoring files.
+ * @property {string[]} [disableRule] - Linter rules to disable.
+ * @property {boolean} [dryRun] - Dry-run mode.
+ * @property {keyof reporters} reporter - Reporter for linter output.
+ */
+
+/**
+ * Run the linter on parsed documentation.
+ * @param {ApiDocMetadataEntry[]} docs - Parsed documentation objects.
+ * @param {LinterOptions} options - Linter configuration options.
+ * @returns {boolean} - True if no errors, false otherwise.
+ */
+export function runLint(
+  docs,
+  { disableRule = [], dryRun = false, reporter = 'console' } = {}
+) {
+  const linter = createLinter(dryRun, disableRule);
+  linter.lintAll(docs);
+  linter.report(reporter);
+  return !linter.hasError();
+}
+
+/**
+ * @type {import('../utils.mjs').Command}
+ */
+export default {
+  name: 'lint',
+  description: 'Run linter independently',
+  options: {
+    input: {
+      flags: ['-i', '--input <patterns...>'],
+      desc: 'Input file patterns (glob)',
+      prompt: {
+        type: 'text',
+        message: 'Enter input glob patterns',
+        variadic: true,
+        required: true,
+      },
+    },
+    ignore: {
+      flags: ['--ignore [patterns...]'],
+      desc: 'Ignore patterns (comma-separated)',
+      prompt: {
+        type: 'text',
+        message: 'Enter ignore patterns',
+        variadic: true,
+      },
+    },
+    disableRule: {
+      flags: ['--disable-rule [rules...]'],
+      desc: 'Disable linter rules',
+      prompt: {
+        type: 'multiselect',
+        message: 'Choose rules to disable',
+        options: availableRules.map(r => ({ label: r, value: r })),
+      },
+    },
+    dryRun: {
+      flags: ['--dry-run'],
+      desc: 'Dry run mode',
+      prompt: {
+        type: 'confirm',
+        message: 'Enable dry run mode?',
+        initialValue: false,
+      },
+    },
+    reporter: {
+      flags: ['-r', '--reporter <reporter>'],
+      desc: 'Linter reporter to use',
+      prompt: {
+        type: 'select',
+        message: 'Choose a reporter',
+        options: availableReporters.map(r => ({ label: r, value: r })),
+      },
+    },
+  },
+
+  /**
+   * Action for running the linter
+   * @param {LinterOptions} opts - Linter options.
+   * @returns {Promise<void>}
+   */
+  async action(opts) {
+    try {
+      const docs = await loadAndParse(opts.input, opts.ignore);
+      const success = runLint(docs, opts);
+      process.exitCode = success ? 0 : 1;
+    } catch (error) {
+      console.error('Error running the linter:', error);
+      process.exitCode = 1;
+    }
+  },
+};
diff --git a/bin/commands/list.mjs b/bin/commands/list.mjs
new file mode 100644
index 00000000..2bb898a8
--- /dev/null
+++ b/bin/commands/list.mjs
@@ -0,0 +1,26 @@
+import { publicGenerators } from '../../src/generators/index.mjs';
+import reporters from '../../src/linter/reporters/index.mjs';
+import rules from '../../src/linter/rules/index.mjs';
+
+const availableRules = Object.keys(rules);
+const availableReporters = Object.keys(reporters);
+
+/**
+ *
+ * @param type
+ */
+export default function list(type) {
+  const list =
+    type === 'generators'
+      ? Object.entries(publicGenerators).map(
+          ([key, generator]) =>
+            `${generator.name || key} (v${generator.version}) - ${generator.description}`
+        )
+      : type === 'rules'
+        ? availableRules
+        : availableReporters;
+
+  console.log(list.join('\n'));
+}
+
+export const types = ['generators', 'rules', 'reporters'];
diff --git a/bin/utils.mjs b/bin/utils.mjs
new file mode 100644
index 00000000..45ceff2d
--- /dev/null
+++ b/bin/utils.mjs
@@ -0,0 +1,40 @@
+import createMarkdownLoader from '../src/loaders/markdown.mjs';
+import createMarkdownParser from '../src/parsers/markdown.mjs';
+
+// Instantiate loader and parser once to reuse
+const loader = createMarkdownLoader();
+const parser = createMarkdownParser();
+
+/**
+ * Load and parse markdown API docs.
+ * @param {string[]} input - Glob patterns for input files.
+ * @param {string[]} [ignore] - Glob patterns to ignore.
+ * @returns {Promise<ApiDocMetadataEntry[]>} - Parsed documentation objects.
+ */
+export async function loadAndParse(input, ignore) {
+  const files = await loader.loadFiles(input, ignore);
+  return parser.parseApiDocs(files);
+}
+
+/**
+ * Represents a command-line option for the linter CLI.
+ * @typedef {Object} Option
+ * @property {string[]} flags - Command-line flags, e.g., ['-i, --input <patterns...>'].
+ * @property {string} desc - Description of the option.
+ * @property {Object} [prompt] - Optional prompt configuration.
+ * @property {'text'|'confirm'|'select'|'multiselect'} prompt.type - Type of the prompt.
+ * @property {string} prompt.message - Message displayed in the prompt.
+ * @property {boolean} [prompt.variadic] - Indicates if the prompt accepts multiple values.
+ * @property {boolean} [prompt.required] - Whether the prompt is required.
+ * @property {boolean} [prompt.initialValue] - Default value for confirm prompts.
+ * @property {{label: string, value: string}[]} [prompt.options] - Options for select/multiselect prompts.
+ */
+
+/**
+ * Represents a command-line subcommand
+ * @typedef {Object} Command
+ * @property {{ [key: string]: Option }} options
+ * @property {string} name
+ * @property {string} description
+ * @property {Function} action
+ */
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 70bc0027..7d008d7a 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -6,7 +6,7 @@ import globals from 'globals';
 export default [
   // @see https://eslint.org/docs/latest/use/configure/configuration-files#specifying-files-and-ignores
   {
-    files: ['src/**/*.mjs', 'bin/cli.mjs'],
+    files: ['src/**/*.mjs', 'bin/**/*.mjs'],
     plugins: {
       jsdoc: jsdoc,
     },
diff --git a/src/generators/legacy-html/utils/buildContent.mjs b/src/generators/legacy-html/utils/buildContent.mjs
index 2becb171..15d7b57a 100644
--- a/src/generators/legacy-html/utils/buildContent.mjs
+++ b/src/generators/legacy-html/utils/buildContent.mjs
@@ -109,10 +109,7 @@ const buildMetadataElement = node => {
       : node.added_in;
 
     // Creates the added in element with the added in version
-    const addedinElement = createElement('span', [
-      'Added in: ',
-      addedIn,
-    ]);
+    const addedinElement = createElement('span', ['Added in: ', addedIn]);
 
     // Appends the added in element to the metadata element
     metadataElement.children.push(addedinElement);
@@ -141,10 +138,7 @@ const buildMetadataElement = node => {
       : node.removed_in;
 
     // Creates the removed in element with the removed in version
-    const removedInElement = createElement('span', [
-      'Removed in: ',
-      removedIn,
-    ]);
+    const removedInElement = createElement('span', ['Removed in: ', removedIn]);
 
     // Appends the removed in element to the metadata element
     metadataElement.children.push(removedInElement);

From d480352836685d019d8fc233112aecc174f7feef Mon Sep 17 00:00:00 2001
From: avivkeller <me@aviv.sh>
Date: Wed, 16 Apr 2025 12:55:30 -0400
Subject: [PATCH 3/7] add jsdoc

---
 bin/commands/list.mjs | 7 ++++---
 1 file changed, 4 insertions(+), 3 deletions(-)

diff --git a/bin/commands/list.mjs b/bin/commands/list.mjs
index 2bb898a8..50bfb810 100644
--- a/bin/commands/list.mjs
+++ b/bin/commands/list.mjs
@@ -5,9 +5,12 @@ import rules from '../../src/linter/rules/index.mjs';
 const availableRules = Object.keys(rules);
 const availableReporters = Object.keys(reporters);
 
+export const types = ['generators', 'rules', 'reporters'];
+
 /**
+ * Lists available generators, rules, or reporters based on the given type.
  *
- * @param type
+ * @param {'generators' | 'rules' | 'reporters'} type - The type of items to list.
  */
 export default function list(type) {
   const list =
@@ -22,5 +25,3 @@ export default function list(type) {
 
   console.log(list.join('\n'));
 }
-
-export const types = ['generators', 'rules', 'reporters'];

From fbb3009ac86dc5687cfea835f3698a4aa7242dfb Mon Sep 17 00:00:00 2001
From: avivkeller <me@aviv.sh>
Date: Wed, 16 Apr 2025 12:59:38 -0400
Subject: [PATCH 4/7] include boolean flags in final exec

---
 bin/commands/interactive.mjs | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/bin/commands/interactive.mjs b/bin/commands/interactive.mjs
index 0cdf0884..840e2f2d 100644
--- a/bin/commands/interactive.mjs
+++ b/bin/commands/interactive.mjs
@@ -145,7 +145,10 @@ export default async function interactive() {
 
     // Handle different value types (boolean, array, string)
     if (typeof value === 'boolean') {
-      if (value) cmdParts.push(flag);
+      if (value) {
+        cmdParts.push(flag);
+        executionArgs.push(flag);
+      }
     } else if (Array.isArray(value)) {
       for (const item of value) {
         cmdParts.push(flag, escapeShellArg(item));

From 91d26b2e9dbcc3b9c626a7faa7ee15d345b3d2f2 Mon Sep 17 00:00:00 2001
From: Aviv Keller <me@aviv.sh>
Date: Thu, 17 Apr 2025 09:08:34 -0400
Subject: [PATCH 5/7] suggestion from review

Co-authored-by: flakey5 <73616808+flakey5@users.noreply.github.com>
---
 bin/commands/interactive.mjs | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/bin/commands/interactive.mjs b/bin/commands/interactive.mjs
index 840e2f2d..de5b24e5 100644
--- a/bin/commands/interactive.mjs
+++ b/bin/commands/interactive.mjs
@@ -139,7 +139,10 @@ export default async function interactive() {
 
   for (const [key, { flags }] of Object.entries(options)) {
     const value = answers[key];
-    if (value == null || (Array.isArray(value) && value.length === 0)) continue; // Skip empty values
+    // Skip empty values
+    if (value == null || (Array.isArray(value) && value.length === 0)) {
+      continue;
+    }
 
     const flag = flags[0].split(/[\s,]+/)[0]; // Use the first flag
 

From 4d1a9dea4c49a2a0cdae4eae411c8c50636cb207 Mon Sep 17 00:00:00 2001
From: avivkeller <me@aviv.sh>
Date: Thu, 17 Apr 2025 15:43:46 -0400
Subject: [PATCH 6/7] code review

---
 README.md   | 65 +++++++++--------------------------------------------
 bin/cli.mjs |  9 --------
 2 files changed, 11 insertions(+), 63 deletions(-)

diff --git a/README.md b/README.md
index b9979dea..1b7124bd 100644
--- a/README.md
+++ b/README.md
@@ -23,61 +23,18 @@
 
 ## Usage
 
-### `help`
-
-```sh
-npx api-docs-tooling help [command]
-```
-
-### `generate`
-
-Generate API documentation from Markdown files.
-
-```sh
-npx api-docs-tooling generate [options]
-```
-
-**Options:**
-
-- `-i, --input <patterns...>` Input file patterns (glob)
-- `--ignore [patterns...]` Files to ignore
-- `-o, --output <dir>` Output directory
-- `-v, --version <semver>` Target Node.js version (default: latest)
-- `-c, --changelog <url>` Changelog file or URL
-- `--git-ref <url>` Git ref/commit URL
-- `-t, --target [modes...]` Generator target(s): `json-simple`, `legacy-html`, etc.
-- `--no-lint` Skip linting before generation
-
-### `lint`
-
-Run the linter on API documentation.
-
-```sh
-npx api-docs-tooling lint [options]
 ```
+Usage: api-docs-tooling [options] [command]
 
-**Options:**
-
-- `-i, --input <patterns...>` Input file patterns (glob)
-- `--ignore [patterns...]` Files to ignore
-- `--disable-rule [rules...]` Disable specific linting rules
-- `--dry-run` Run linter without applying changes
-- `-r, --reporter <reporter>` Reporter format: `console`, `github`, etc.
+CLI tool to generate and lint Node.js API documentation
 
-### `interactive`
+Options:
+  -h, --help          display help for command
 
-Launches a fully interactive CLI prompt to guide you through all available options.
-
-```sh
-npx api-docs-tooling interactive
-```
-
-### `list`
-
-See available modules for each subsystem.
-
-```sh
-npx api-docs-tooling list generators
-npx api-docs-tooling list rules
-npx api-docs-tooling list reporters
-```
+Commands:
+  generate [options]  Generate API docs
+  lint [options]      Run linter independently
+  interactive         Launch guided CLI wizard
+  list <types>        List the given type
+  help [command]      display help for command
+```
\ No newline at end of file
diff --git a/bin/cli.mjs b/bin/cli.mjs
index 748abb77..c5019de9 100755
--- a/bin/cli.mjs
+++ b/bin/cli.mjs
@@ -49,14 +49,5 @@ program
   .description('List the given type')
   .action(list);
 
-// Register the help command
-program
-  .command('help [cmd]')
-  .description('Show help for a command')
-  .action(cmdName => {
-    const target = program.commands.find(c => c.name() === cmdName) ?? program;
-    target.help();
-  });
-
 // Parse and execute command-line arguments
 program.parse(process.argv);

From 780f9e48086101d6bc00e58c16cc5cc352efde8c Mon Sep 17 00:00:00 2001
From: avivkeller <me@aviv.sh>
Date: Thu, 17 Apr 2025 18:04:05 -0400
Subject: [PATCH 7/7] show basic help

---
 README.md | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 68 insertions(+), 1 deletion(-)

diff --git a/README.md b/README.md
index 1b7124bd..49b46857 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,12 @@
 
 ## Usage
 
+Local invocation:
+
+```sh
+$ npx api-docs-tooling --help
+```
+
 ```
 Usage: api-docs-tooling [options] [command]
 
@@ -37,4 +43,65 @@ Commands:
   interactive         Launch guided CLI wizard
   list <types>        List the given type
   help [command]      display help for command
-```
\ No newline at end of file
+```
+
+### `generate`
+
+```
+Usage: api-docs-tooling generate [options]
+
+Generate API docs
+
+Options:
+  -i, --input <patterns...>  Input file patterns (glob)
+  --ignore [patterns...]     Ignore patterns (comma-separated)
+  -o, --output <dir>         Output directory
+  -p, --threads <number>      (default: "12")
+  -v, --version <semver>     Target Node.js version (default: "v22.14.0")
+  -c, --changelog <url>      Changelog URL or path (default: "https://raw.githubusercontent.com/nodejs/node/HEAD/CHANGELOG.md")
+  --git-ref <url>            Git ref/commit URL (default: "https://github.com/nodejs/node/tree/HEAD")
+  -t, --target [modes...]    Target generator modes (choices: "json-simple", "legacy-html", "legacy-html-all", "man-page", "legacy-json", "legacy-json-all", "addon-verify", "api-links", "orama-db")
+  --no-lint                  Skip lint before generate
+  -h, --help                 display help for command
+```
+
+### `lint`
+
+```
+Usage: api-docs-tooling lint [options]
+
+Run linter independently
+
+Options:
+  -i, --input <patterns...>  Input file patterns (glob)
+  --ignore [patterns...]     Ignore patterns (comma-separated)
+  --disable-rule [rules...]  Disable linter rules (choices: "duplicate-stability-nodes", "invalid-change-version", "missing-introduced-in")
+  --dry-run                  Dry run mode (default: false)
+  -r, --reporter <reporter>  Linter reporter to use
+  -h, --help                 display help for command
+```
+
+### `interactive`
+
+```
+Usage: api-docs-tooling interactive [options]
+
+Launch guided CLI wizard
+
+Options:
+  -h, --help  display help for command
+```
+
+### `list`
+
+```
+Usage: api-docs-tooling list [options] <types>
+
+List the given type
+
+Arguments:
+  types       The type to list (choices: "generators", "rules", "reporters")
+
+Options:
+  -h, --help  display help for command
+```