Repository: Redocly/redocly-cli
Pull request #1136 (2023-06-20 11:11:46)

chore: refactor CLI commands (use command wrapper)
## What/Why/How?



- Refactored the CLI to use a command wrapper. The wrapper helps organise code by extracting repetitive things (loading the config, sending telemetry) from the command handlers themselves.
- Introduced telemetry.
- Implemented opt-outing through the env variable `REDOCLY_TELEMETRY=off` and Redocly config: `telemetry: off`.



Naming is under consideration. Feel free to make a suggestion.

> TODO: merge related docs after releasing this changes: https://github.com/Redocly/redocly-cli/pull/1139

## Reference

Closes https://github.com/Redocly/redocly-cli/issues/1044 

## Testing

## Screenshots (optional)

## Check yourself

- [x] Code is linted
- [x] Tested with redoc/reference-docs/workflows
- [x] All new/updated code is covered with tests

## Security

- [x] Security impact of change has been considered
- [x] Code follows company security practices and guidelines


github-actions[bot]: | Command | Mean [s] | Min [s] | Max [s] | Relative |
|:---|---:|---:|---:|---:|
| `redocly lint packages/core/src/benchmark/benches/rebilly.yaml` | 1.269 ± 0.026 | 1.240 | 1.306 | 1.00 |
| `redocly-next lint packages/core/src/benchmark/benches/rebilly.yaml` | 1.451 ± 0.045 | 1.389 | 1.523 | 1.14 ± 0.04 |

<!-- thollander/actions-comment-pull-request "hyperfine-comparison" -->
github-actions[bot]: <!-- jest coverage report action for options with hash 3339ae53a38c44382c0fb4491bdb282d -->

## Coverage report 

❌ An unexpected error occurred. For more details, [check console](https://github.com/Redocly/redocly-cli/actions/runs/5321835659) 
```
Error: The process '/opt/hostedtoolcache/node/18.16.0/x64/bin/npm' failed with exit code 1
```

| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | Category   | Percentage | Covered / Total |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :--------- | :--------- | :-------------: |
|                                                                           🟡                                                                           | Statements | 73.37%     |    3555/4845    |
|                                                                           🟡                                                                           | Branches   | 64.66%     |    2005/3101    |
|                                                                           🟡                                                                           | Functions  | 65.34%     |     592/906     |
|                                                                           🟡                                                                           | Lines      | 73.43%     |    3310/4508    |

<details><summary>Show new covered files 🐣</summary>
<br/>

| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | File               | Statements | Branches | Functions | Lines  |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------- | :--------- | :------- | :-------- | :----- |
|                                                                           🔴                                                                           | cli/src/wrapper.ts | 41.18%     | 23.53%   | 40%       | 41.67% |

</details>


<details><summary>Show files with reduced coverage 🔻</summary>
<br/>

| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | File                                                                     | Statements                                                     | Branches                                                       | Functions                                              | Lines                                                          |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------- | :------------------------------------------------------------- | :------------------------------------------------------------- | :----------------------------------------------------- | :------------------------------------------------------------- |
|                                                                           🔴                                                                           | cli/src/utils.ts                                                         | 59.66%                                                         | 46.49%                                                         | 70.73%                                                 | 59.2%                                                          |
|                                                                           🟡                                                                           | <div title="cli/src/commands/push.ts">`...` / push.ts</div>              | 71.81%                                                         | 63.89%                                                         | 57.14%                                                 | 74.29%                                                         |
|                                                                           🟢                                                                           | <div title="cli/src/commands/lint.ts">`...` / lint.ts</div>              | <div title="Base coverage is: 78.33%">82.46% (+4.12% 🔼)</div> | <div title="Base coverage is: 77.78%">71.43% (-6.35% 🔻)</div> | <div title="Base coverage is: 60%">50% (-10% 🔻)</div> | <div title="Base coverage is: 79.66%">83.93% (+4.27% 🔼)</div> |
|                                                                           🟢                                                                           | <div title="cli/src/commands/bundle.ts">`...` / bundle.ts</div>          | 87.1%                                                          | 69.77%                                                         | 100%                                                   | 87.1%                                                          |
|                                                                           🟡                                                                           | <div title="cli/src/commands/join.ts">`...` / join.ts</div>              | 59.82%                                                         | 42.54%                                                         | 58.82%                                                 | 61.08%                                                         |
|                                                                           🟢                                                                           | <div title="cli/src/commands/build-docs/index.ts">`...` / index.ts</div> | 96%                                                            | 58.33%                                                         | 100%                                                   | 95.65%                                                         |

</details>

## Test suite run failed
Failed tests: 0/590. Failed suites: 0/87.

<p align="right">Report generated by <a href="https://github.com/ArtiomTr/jest-coverage-report-action">🧪jest coverage report action</a> from c6d23bbfc3ccb765afdfe9ef0076b26ded09f9c0</p>


Patch:
diff --git a/.github/workflows/performance.yaml b/.github/workflows/performance.yaml
index 424734c5..defe786c 100644
--- a/.github/workflows/performance.yaml
+++ b/.github/workflows/performance.yaml
@@ -28,7 +28,7 @@ jobs:
       - run: redocly-next --version
       - run: redocly --version
       - name: Run Benchmark
-        run: hyperfine -i --warmup 3 'redocly lint packages/core/src/benchmark/benches/rebilly.yaml' 'redocly-next lint packages/core/src/benchmark/benches/rebilly.yaml' --export-markdown benchmark_check.md --export-json benchmark_check.json
+        run: hyperfine -i --warmup 3 'REDOCLY_TELEMETRY=off redocly lint packages/core/src/benchmark/benches/rebilly.yaml' 'REDOCLY_TELEMETRY=off redocly-next lint packages/core/src/benchmark/benches/rebilly.yaml' --export-markdown benchmark_check.md --export-json benchmark_check.json
         env:
           CI: true
       - name: Comment PR
diff --git a/jest.config.js b/jest.config.js
index 29884bcf..8212fca6 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -18,9 +18,9 @@ module.exports = {
       lines: 80,
     },
     'packages/cli/': {
-      statements: 54,
-      branches: 45,
-      functions: 53,
+      statements: 55,
+      branches: 46,
+      functions: 55,
       lines: 55,
     },
   },
diff --git a/package.json b/package.json
index af6867db..bff5f41a 100644
--- a/package.json
+++ b/package.json
@@ -9,12 +9,12 @@
   "engineStrict": true,
   "scripts": {
     "test": "npm run typecheck && npm run unit",
-    "jest": "jest ./packages",
+    "jest": "REDOCLY_TELEMETRY=off jest ./packages",
     "unit": "npm run jest -- --coverage --coverageReporters lcov text-summary",
-    "coverage:cli": "jest --roots packages/cli/src --coverage",
-    "coverage:core": "jest --roots packages/core/src --coverage",
+    "coverage:cli": "npm run jest -- --roots packages/cli/src --coverage",
+    "coverage:core": "npm run jest -- --roots packages/core/src --coverage",
     "typecheck": "tsc --noEmit --skipLibCheck",
-    "e2e": "npm run webpack-bundle -- --mode=none && jest --roots=./__tests__/",
+    "e2e": "npm run webpack-bundle -- --mode=none && REDOCLY_TELEMETRY=off jest --roots=./__tests__/",
     "prettier": "npx prettier --write \"**/*.{ts,js,yaml,json}\"",
     "prettier:check": "npx prettier --check \"**/*.{ts,js,yaml,json}\"",
     "eslint": "eslint packages/**",
diff --git a/packages/cli/src/commands/build-docs/index.ts b/packages/cli/src/commands/build-docs/index.ts
index 33439949..865f4016 100644
--- a/packages/cli/src/commands/build-docs/index.ts
+++ b/packages/cli/src/commands/build-docs/index.ts
@@ -5,18 +5,12 @@ import { performance } from 'perf_hooks';
 
 import { getObjectOrJSON, getPageHTML } from './utils';
 import type { BuildDocsArgv } from './types';
-import { getMergedConfig, isAbsoluteUrl } from '@redocly/openapi-core';
-import {
-  exitWithError,
-  getExecutionTime,
-  getFallbackApisOrExit,
-  loadConfigAndHandleErrors,
-} from '../../utils';
-
-export const handlerBuildCommand = async (argv: BuildDocsArgv) => {
+import { Config, getMergedConfig, isAbsoluteUrl } from '@redocly/openapi-core';
+import { exitWithError, getExecutionTime, getFallbackApisOrExit } from '../../utils';
+
+export const handlerBuildCommand = async (argv: BuildDocsArgv, configFromFile: Config) => {
   const startedAt = performance.now();
 
-  const configFromFile = await loadConfigAndHandleErrors({ configPath: argv.config });
   const config = getMergedConfig(configFromFile, argv.api);
 
   const apis = await getFallbackApisOrExit(argv.api ? [argv.api] : [], config);
diff --git a/packages/cli/src/commands/bundle.ts b/packages/cli/src/commands/bundle.ts
index 95cb0dac..9451edc2 100644
--- a/packages/cli/src/commands/bundle.ts
+++ b/packages/cli/src/commands/bundle.ts
@@ -1,4 +1,12 @@
-import { formatProblems, getTotals, getMergedConfig, lint, bundle } from '@redocly/openapi-core';
+import {
+  formatProblems,
+  getTotals,
+  getMergedConfig,
+  lint,
+  bundle,
+  Config,
+  OutputFormat,
+} from '@redocly/openapi-core';
 import {
   dumpBundle,
   getExecutionTime,
@@ -8,32 +16,31 @@ import {
   printUnusedWarnings,
   saveBundle,
   printLintTotals,
-  loadConfigAndHandleErrors,
   checkIfRulesetExist,
   sortTopLevelKeysForOas,
 } from '../utils';
-import type { CommonOptions, OutputExtensions, Skips, Totals } from '../types';
+import type { OutputExtensions, Skips, Totals } from '../types';
 import { performance } from 'perf_hooks';
 import { blue, gray, green, yellow } from 'colorette';
 import { writeFileSync } from 'fs';
 
-export type BundleOptions = CommonOptions &
-  Skips & {
-    output?: string;
-    ext: OutputExtensions;
-    dereferenced?: boolean;
-    force?: boolean;
-    lint?: boolean;
-    metafile?: string;
-    'remove-unused-components'?: boolean;
-    'keep-url-references'?: boolean;
-  };
-
-export async function handleBundle(argv: BundleOptions, version: string) {
-  const config = await loadConfigAndHandleErrors({
-    configPath: argv.config,
-    customExtends: argv.extends,
-  });
+export type BundleOptions = {
+  apis?: string[];
+  'max-problems': number;
+  extends?: string[];
+  config?: string;
+  format: OutputFormat;
+  output?: string;
+  ext: OutputExtensions;
+  dereferenced?: boolean;
+  force?: boolean;
+  lint?: boolean;
+  metafile?: string;
+  'remove-unused-components'?: boolean;
+  'keep-url-references'?: boolean;
+} & Skips;
+
+export async function handleBundle(argv: BundleOptions, config: Config, version: string) {
   const removeUnusedComponents =
     argv['remove-unused-components'] ||
     config.rawConfig?.styleguide?.decorators?.hasOwnProperty('remove-unused-components');
@@ -164,7 +171,7 @@ export async function handleBundle(argv: BundleOptions, version: string) {
 
   printUnusedWarnings(config.styleguide);
 
-  // defer process exit to allow STDOUT pipe to flush
-  // see https://github.com/nodejs/node-v0.x-archive/issues/3737#issuecomment-19156072
-  process.once('exit', () => process.exit(totals.errors === 0 || argv.force ? 0 : 1));
+  if (!(totals.errors === 0 || argv.force)) {
+    throw new Error('Bundle failed.');
+  }
 }
diff --git a/packages/cli/src/commands/join.ts b/packages/cli/src/commands/join.ts
index a1c990d3..7d683853 100644
--- a/packages/cli/src/commands/join.ts
+++ b/packages/cli/src/commands/join.ts
@@ -26,7 +26,6 @@ import {
   printLintTotals,
   writeYaml,
   exitWithError,
-  loadConfigAndHandleErrors,
   sortTopLevelKeysForOas,
 } from '../utils';
 import { isObject, isString, keysOf } from '../js-utils';
@@ -48,7 +47,7 @@ type JoinDocumentContext = {
   componentsPrefix: string | undefined;
 };
 
-type JoinArgv = {
+export type JoinOptions = {
   apis: string[];
   lint?: boolean;
   decorate?: boolean;
@@ -58,9 +57,12 @@ type JoinArgv = {
   'prefix-components-with-info-prop'?: string;
   'without-x-tag-groups'?: boolean;
   output?: string;
+  config?: string;
+  extends?: undefined;
+  'lint-config'?: undefined;
 };
 
-export async function handleJoin(argv: JoinArgv, packageVersion: string) {
+export async function handleJoin(argv: JoinOptions, config: Config, packageVersion: string) {
   const startedAt = performance.now();
   if (argv.apis.length < 2) {
     return exitWithError(`At least 2 apis should be provided. \n\n`);
@@ -86,7 +88,6 @@ export async function handleJoin(argv: JoinArgv, packageVersion: string) {
     );
   }
 
-  const config: Config = await loadConfigAndHandleErrors();
   const apis = await getFallbackApisOrExit(argv.apis, config);
   const externalRefResolver = new BaseResolver(config.resolve);
   const documents = await Promise.all(
diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts
index a724ebc2..cef4a077 100644
--- a/packages/cli/src/commands/lint.ts
+++ b/packages/cli/src/commands/lint.ts
@@ -1,6 +1,5 @@
 import {
   Config,
-  doesYamlFileExist,
   findConfig,
   formatProblems,
   getMergedConfig,
@@ -8,9 +7,6 @@ import {
   lint,
   lintConfig,
   makeDocumentFromString,
-  ProblemSeverity,
-  RawConfig,
-  RuleSeverity,
   stringifyYaml,
 } from '@redocly/openapi-core';
 import {
@@ -19,37 +15,31 @@ import {
   getExecutionTime,
   getFallbackApisOrExit,
   handleError,
-  loadConfigAndHandleErrors,
   pluralize,
   printConfigLintTotals,
   printLintTotals,
   printUnusedWarnings,
 } from '../utils';
-import type { CommonOptions, Skips, Totals } from '../types';
+import type { OutputFormat, ProblemSeverity, RawConfig, RuleSeverity } from '@redocly/openapi-core';
+import type { CommandOptions, Skips, Totals } from '../types';
 import { blue, gray } from 'colorette';
 import { performance } from 'perf_hooks';
 
-export type LintOptions = CommonOptions &
-  Omit<Skips, 'skip-decorator'> & {
-    'generate-ignore-file'?: boolean;
-    'lint-config': RuleSeverity;
-  };
-
-export async function handleLint(argv: LintOptions, version: string) {
-  if (argv.config && !doesYamlFileExist(argv.config)) {
-    return exitWithError('Please, provide valid path to the configuration file');
-  }
-
-  const config: Config = await loadConfigAndHandleErrors({
-    configPath: argv.config,
-    customExtends: argv.extends,
-    processRawConfig: lintConfigCallback(argv, version),
-  });
-
+export type LintOptions = {
+  apis?: string[];
+  'max-problems': number;
+  extends?: string[];
+  config?: string;
+  format: OutputFormat;
+  'generate-ignore-file'?: boolean;
+  'lint-config'?: RuleSeverity;
+} & Omit<Skips, 'skip-decorator'>;
+
+export async function handleLint(argv: LintOptions, config: Config, version: string) {
   const apis = await getFallbackApisOrExit(argv.apis, config);
 
   if (!apis.length) {
-    return exitWithError('No APIs were provided');
+    exitWithError('No APIs were provided');
   }
 
   if (argv['generate-ignore-file']) {
@@ -120,14 +110,15 @@ export async function handleLint(argv: LintOptions, version: string) {
 
   printUnusedWarnings(config.styleguide);
 
-  // defer process exit to allow STDOUT pipe to flush
-  // see https://github.com/nodejs/node-v0.x-archive/issues/3737#issuecomment-19156072
-  process.once('exit', () =>
-    process.exit(totals.errors === 0 || argv['generate-ignore-file'] ? 0 : 1)
-  );
+  if (!(totals.errors === 0 || argv['generate-ignore-file'])) {
+    throw new Error('Lint failed.');
+  }
 }
 
-function lintConfigCallback(argv: LintOptions, version: string) {
+export function lintConfigCallback(
+  argv: CommandOptions & Record<string, undefined>,
+  version: string
+) {
   if (argv['lint-config'] === 'off') {
     return;
   }
@@ -138,7 +129,6 @@ function lintConfigCallback(argv: LintOptions, version: string) {
   }
 
   return async (config: RawConfig) => {
-    const { 'max-problems': maxProblems, format } = argv;
     const configPath = findConfig(argv.config) || '';
     const stringYaml = stringifyYaml(config);
     const configContent = makeDocumentFromString(stringYaml, configPath);
@@ -150,8 +140,8 @@ function lintConfigCallback(argv: LintOptions, version: string) {
     const fileTotals = getTotals(problems);
 
     formatProblems(problems, {
-      format,
-      maxProblems,
+      format: argv.format,
+      maxProblems: argv['max-problems'],
       totals: fileTotals,
       version,
     });
diff --git a/packages/cli/src/commands/login.ts b/packages/cli/src/commands/login.ts
index e99ff0cc..21215b1d 100644
--- a/packages/cli/src/commands/login.ts
+++ b/packages/cli/src/commands/login.ts
@@ -1,6 +1,6 @@
-import { Region, RedoclyClient } from '@redocly/openapi-core';
+import { Region, RedoclyClient, Config } from '@redocly/openapi-core';
 import { blue, green, gray } from 'colorette';
-import { loadConfigAndHandleErrors, promptUser } from '../utils';
+import { promptUser } from '../utils';
 
 export function promptClientToken(domain: string) {
   return promptUser(
@@ -11,8 +11,14 @@ export function promptClientToken(domain: string) {
   );
 }
 
-export async function handleLogin(argv: { verbose?: boolean; region?: Region }) {
-  const region = argv.region || (await loadConfigAndHandleErrors()).region;
+export type LoginOptions = {
+  verbose?: boolean;
+  region?: Region;
+  config?: string;
+};
+
+export async function handleLogin(argv: LoginOptions, config: Config) {
+  const region = argv.region || config.region;
   const client = new RedoclyClient(region);
   const clientToken = await promptClientToken(client.domain);
   process.stdout.write(gray('\n  Logging in...\n'));
diff --git a/packages/cli/src/commands/preview-docs/index.ts b/packages/cli/src/commands/preview-docs/index.ts
index 5690fe93..0e8de9a9 100644
--- a/packages/cli/src/commands/preview-docs/index.ts
+++ b/packages/cli/src/commands/preview-docs/index.ts
@@ -7,24 +7,25 @@ import {
   RedoclyClient,
   getTotals,
   getMergedConfig,
+  Config,
 } from '@redocly/openapi-core';
 import { getFallbackApisOrExit, loadConfigAndHandleErrors } from '../../utils';
 import startPreviewServer from './preview-server/preview-server';
 import type { Skips } from '../../types';
 
-export async function previewDocs(
-  argv: {
-    port: number;
-    host: string;
-    'use-community-edition'?: boolean;
-    config?: string;
-    api?: string;
-    force?: boolean;
-  } & Omit<Skips, 'skip-rule'>
-) {
+export type PreviewDocsOptions = {
+  port: number;
+  host: string;
+  'use-community-edition'?: boolean;
+  config?: string;
+  api?: string;
+  force?: boolean;
+} & Omit<Skips, 'skip-rule'>;
+
+export async function previewDocs(argv: PreviewDocsOptions, configFromFile: Config) {
   let isAuthorizedWithRedocly = false;
   let redocOptions: any = {};
-  let config = await reloadConfig();
+  let config = await reloadConfig(configFromFile);
 
   const apis = await getFallbackApisOrExit(argv.api ? [argv.api] : [], config);
   const api = apis[0];
@@ -127,8 +128,14 @@ export async function previewDocs(
     );
   });
 
-  async function reloadConfig() {
-    const config = await loadConfigAndHandleErrors({ configPath: argv.config });
+  async function reloadConfig(config?: Config) {
+    if (!config) {
+      try {
+        config = (await loadConfigAndHandleErrors({ configPath: argv.config })) as Config;
+      } catch (err) {
+        config = new Config({ apis: {}, styleguide: {} });
+      }
+    }
     const redoclyClient = new RedoclyClient();
     isAuthorizedWithRedocly = await redoclyClient.isAuthorizedWithRedocly();
     const resolvedConfig = getMergedConfig(config, argv.api);
diff --git a/packages/cli/src/commands/push.ts b/packages/cli/src/commands/push.ts
index 09ad9107..c4423edb 100644
--- a/packages/cli/src/commands/push.ts
+++ b/packages/cli/src/commands/push.ts
@@ -21,16 +21,15 @@ import {
   getFallbackApisOrExit,
   pluralize,
   dumpBundle,
-  loadConfigAndHandleErrors,
 } from '../utils';
 import { promptClientToken } from './login';
 
 const DEFAULT_VERSION = 'latest';
 
-const DESTINATION_REGEX =
+export const DESTINATION_REGEX =
   /^(@(?<organizationId>[\w\-\s]+)\/)?(?<name>[^@]*)@(?<version>[\w\.\-]+)$/;
 
-type PushArgs = {
+export type PushOptions = {
   api?: string;
   destination?: string;
   branchName?: string;
@@ -41,10 +40,10 @@ type PushArgs = {
   'skip-decorator'?: string[];
   public?: boolean;
   files?: string[];
+  config?: string;
 };
 
-export async function handlePush(argv: PushArgs): Promise<void> {
-  const config = await loadConfigAndHandleErrors({ region: argv.region, files: argv.files });
+export async function handlePush(argv: PushOptions, config: Config): Promise<void> {
   const client = new RedoclyClient(config.region);
   const isAuthorized = await client.isAuthorizedWithRedoclyByRegion();
   if (!isAuthorized) {
@@ -329,7 +328,7 @@ export function getDestinationProps(
   }
 }
 
-type BarePushArgs = Omit<PushArgs, 'api' | 'destination' | 'branchName'> & {
+type BarePushArgs = Omit<PushOptions, 'api' | 'destination' | 'branchName'> & {
   maybeApiOrDestination?: string;
   maybeDestination?: string;
   maybeBranchName?: string;
@@ -338,7 +337,10 @@ type BarePushArgs = Omit<PushArgs, 'api' | 'destination' | 'branchName'> & {
 
 export const transformPush =
   (callback: typeof handlePush) =>
-  ({ maybeApiOrDestination, maybeDestination, maybeBranchName, branch, ...rest }: BarePushArgs) => {
+  (
+    { maybeApiOrDestination, maybeDestination, maybeBranchName, branch, ...rest }: BarePushArgs,
+    config: Config
+  ) => {
     if (maybeBranchName) {
       process.stderr.write(
         yellow(
@@ -348,12 +350,15 @@ export const transformPush =
     }
     const api = maybeDestination ? maybeApiOrDestination : undefined;
     const destination = maybeDestination || maybeApiOrDestination;
-    return callback({
-      ...rest,
-      destination,
-      api,
-      branchName: branch ?? maybeBranchName,
-    });
+    return callback(
+      {
+        ...rest,
+        destination,
+        api,
+        branchName: branch ?? maybeBranchName,
+      },
+      config
+    );
   };
 
 export function getApiRoot({
diff --git a/packages/cli/src/commands/split/index.ts b/packages/cli/src/commands/split/index.ts
index 5eeddca7..1f22ae3f 100644
--- a/packages/cli/src/commands/split/index.ts
+++ b/packages/cli/src/commands/split/index.ts
@@ -35,7 +35,14 @@ import {
   Referenced,
 } from './types';
 
-export async function handleSplit(argv: { api: string; outDir: string; separator: string }) {
+export type SplitOptions = {
+  api: string;
+  outDir: string;
+  separator: string;
+  config?: string;
+};
+
+export async function handleSplit(argv: SplitOptions) {
   const startedAt = performance.now();
   const { api, outDir, separator } = argv;
   validateDefinitionFileName(api!);
diff --git a/packages/cli/src/commands/stats.ts b/packages/cli/src/commands/stats.ts
index 793af329..7876b62a 100755
--- a/packages/cli/src/commands/stats.ts
+++ b/packages/cli/src/commands/stats.ts
@@ -6,22 +6,19 @@ import {
   normalizeTypes,
   Oas3Types,
   Oas2Types,
-  StatsAccumulator,
-  StatsName,
   BaseResolver,
   resolveDocument,
   detectOpenAPI,
   OasMajorVersion,
   openAPIMajor,
   normalizeVisitors,
-  WalkContext,
   walkDocument,
   Stats,
   bundle,
 } from '@redocly/openapi-core';
-
-import { getFallbackApisOrExit, loadConfigAndHandleErrors } from '../utils';
+import { getFallbackApisOrExit } from '../utils';
 import { printExecutionTime } from '../utils';
+import type { StatsAccumulator, StatsName, WalkContext, OutputFormat } from '@redocly/openapi-core';
 
 const statsAccumulator: StatsAccumulator = {
   refs: { metric: '🚗 References', total: 0, color: 'red', items: new Set() },
@@ -64,8 +61,13 @@ function printStats(statsAccumulator: StatsAccumulator, api: string, format: str
   }
 }
 
-export async function handleStats(argv: { config?: string; api?: string; format: string }) {
-  const config: Config = await loadConfigAndHandleErrors({ configPath: argv.config });
+export type StatsOptions = {
+  api?: string;
+  format: OutputFormat;
+  config?: string;
+};
+
+export async function handleStats(argv: StatsOptions, config: Config) {
   const [{ path }] = await getFallbackApisOrExit(argv.api ? [argv.api] : [], config);
   const externalRefResolver = new BaseResolver(config.resolve);
   const { bundle: document } = await bundle({ config, ref: path });
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 7fc884d2..79821ed1 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -3,7 +3,7 @@
 import './assert-node-version';
 import * as yargs from 'yargs';
 import { outputExtensions, regionChoices } from './types';
-import { RedoclyClient, OutputFormat, RuleSeverity } from '@redocly/openapi-core';
+import { RedoclyClient } from '@redocly/openapi-core';
 import { previewDocs } from './commands/preview-docs';
 import { handleStats } from './commands/stats';
 import { handleSplit } from './commands/split';
@@ -13,17 +13,19 @@ import { handleLint } from './commands/lint';
 import { handleBundle } from './commands/bundle';
 import { handleLogin } from './commands/login';
 import { handlerBuildCommand } from './commands/build-docs';
-import type { BuildDocsArgv } from './commands/build-docs/types';
 import { cacheLatestVersion, notifyUpdateCliVersion } from './update-version-notifier';
-
-const version = require('../package.json').version;
+import { commandWrapper } from './wrapper';
+import { version } from './update-version-notifier';
+import type { Arguments } from 'yargs';
+import type { OutputFormat, RuleSeverity } from '@redocly/openapi-core';
+import type { BuildDocsArgv } from './commands/build-docs/types';
 
 cacheLatestVersion();
 
 yargs
   .version('version', 'Show version number.', version)
   .help('help', 'Show help.')
-  .parserConfiguration({ 'greedy-arrays': false })
+  .parserConfiguration({ 'greedy-arrays': false, 'camel-case-expansion': false })
   .command(
     'stats [api]',
     'Gathering statistics for a document.',
@@ -38,7 +40,7 @@ yargs
       }),
     (argv) => {
       process.env.REDOCLY_CLI_COMMAND = 'stats';
-      handleStats(argv);
+      commandWrapper(handleStats)(argv);
     }
   )
   .command(
@@ -62,11 +64,16 @@ yargs
             type: 'string',
             default: '_',
           },
+          config: {
+            description: 'Specify path to the config file.',
+            requiresArg: true,
+            type: 'string',
+          },
         })
         .demandOption('api'),
     (argv) => {
       process.env.REDOCLY_CLI_COMMAND = 'split';
-      handleSplit(argv);
+      commandWrapper(handleSplit)(argv);
     }
   )
   .command(
@@ -108,10 +115,15 @@ yargs
             type: 'string',
             default: 'openapi.yaml',
           },
+          config: {
+            description: 'Specify path to the config file.',
+            requiresArg: true,
+            type: 'string',
+          },
         }),
     (argv) => {
       process.env.REDOCLY_CLI_COMMAND = 'join';
-      handleJoin(argv, version);
+      commandWrapper(handleJoin)(argv);
     }
   )
   .command(
@@ -151,12 +163,17 @@ yargs
             array: true,
             type: 'string',
           },
+          config: {
+            description: 'Specify path to the config file.',
+            requiresArg: true,
+            type: 'string',
+          },
         })
         .implies('batch-id', 'batch-size')
         .implies('batch-size', 'batch-id'),
     (argv) => {
       process.env.REDOCLY_CLI_COMMAND = 'push';
-      transformPush(handlePush)(argv);
+      commandWrapper(transformPush(handlePush))(argv);
     }
   )
   .command(
@@ -215,7 +232,7 @@ yargs
       }),
     (argv) => {
       process.env.REDOCLY_CLI_COMMAND = 'lint';
-      handleLint(argv, version);
+      commandWrapper(handleLint)(argv);
     }
   )
   .command(
@@ -297,7 +314,7 @@ yargs
       }),
     (argv) => {
       process.env.REDOCLY_CLI_COMMAND = 'bundle';
-      handleBundle(argv, version);
+      commandWrapper(handleBundle)(argv);
     }
   )
   .command(
@@ -314,21 +331,28 @@ yargs
           alias: 'r',
           choices: regionChoices,
         },
+        config: {
+          description: 'Specify path to the config file.',
+          requiresArg: true,
+          type: 'string',
+        },
       }),
     (argv) => {
       process.env.REDOCLY_CLI_COMMAND = 'login';
-      handleLogin(argv);
+      commandWrapper(handleLogin)(argv);
     }
   )
   .command(
     'logout',
     'Clear your stored credentials for the Redocly API registry.',
     (yargs) => yargs,
-    async () => {
+    async (argv) => {
       process.env.REDOCLY_CLI_COMMAND = 'logout';
-      const client = new RedoclyClient();
-      client.logout();
-      process.stdout.write('Logged out from the Redocly account. ✋\n');
+      await commandWrapper(async () => {
+        const client = new RedoclyClient();
+        client.logout();
+        process.stdout.write('Logged out from the Redocly account. ✋\n');
+      })(argv);
     }
   )
   .command(
@@ -374,7 +398,7 @@ yargs
       }),
     (argv) => {
       process.env.REDOCLY_CLI_COMMAND = 'preview-docs';
-      previewDocs(argv);
+      commandWrapper(previewDocs)(argv);
     }
   )
   .command(
@@ -428,7 +452,7 @@ yargs
         }),
     async (argv) => {
       process.env.REDOCLY_CLI_COMMAND = 'build-docs';
-      handlerBuildCommand(argv as unknown as BuildDocsArgv);
+      commandWrapper(handlerBuildCommand)(argv as Arguments<BuildDocsArgv>);
     }
   )
   .completion('completion', 'Generate completion script.')
diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts
index 373d20ce..c33f2d34 100644
--- a/packages/cli/src/types.ts
+++ b/packages/cli/src/types.ts
@@ -1,4 +1,13 @@
-import type { BundleOutputFormat, OutputFormat, Region, Config } from '@redocly/openapi-core';
+import type { BundleOutputFormat, Region, Config } from '@redocly/openapi-core';
+import type { LintOptions } from './commands/lint';
+import type { BundleOptions } from './commands/bundle';
+import type { JoinOptions } from './commands/join';
+import type { LoginOptions } from './commands/login';
+import type { PushOptions } from './commands/push';
+import type { StatsOptions } from './commands/stats';
+import type { SplitOptions } from './commands/split';
+import type { PreviewDocsOptions } from './commands/preview-docs';
+import type { BuildDocsArgv } from './commands/build-docs/types';
 
 export type Totals = {
   errors: number;
@@ -12,13 +21,16 @@ export type Entrypoint = {
 export const outputExtensions = ['json', 'yaml', 'yml'] as ReadonlyArray<BundleOutputFormat>;
 export type OutputExtensions = 'json' | 'yaml' | 'yml' | undefined;
 export const regionChoices = ['us', 'eu'] as ReadonlyArray<Region>;
-export type CommonOptions = {
-  apis: string[];
-  'max-problems'?: number;
-  extends?: string[];
-  config?: string;
-  format: OutputFormat;
-};
+export type CommandOptions =
+  | StatsOptions
+  | SplitOptions
+  | JoinOptions
+  | PushOptions
+  | LintOptions
+  | BundleOptions
+  | LoginOptions
+  | PreviewDocsOptions
+  | BuildDocsArgv;
 export type Skips = {
   'skip-rule'?: string[];
   'skip-decorator'?: string[];
diff --git a/packages/cli/src/update-version-notifier.ts b/packages/cli/src/update-version-notifier.ts
index 1a42998e..11102966 100644
--- a/packages/cli/src/update-version-notifier.ts
+++ b/packages/cli/src/update-version-notifier.ts
@@ -6,7 +6,7 @@ import fetch from 'node-fetch';
 import { cyan, green, yellow } from 'colorette';
 import { cleanColors } from './utils';
 
-const { version, name } = require('../package.json');
+export const { version, name } = require('../package.json');
 
 const VERSION_CACHE_FILE = 'redocly-cli-version';
 const SPACE_TO_BORDER = 4;
diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts
index 7adbac5f..27e6d321 100644
--- a/packages/cli/src/utils.ts
+++ b/packages/cli/src/utils.ts
@@ -1,3 +1,4 @@
+import fetch from 'node-fetch';
 import { basename, dirname, extname, join, resolve, relative, isAbsolute } from 'path';
 import { blue, gray, green, red, yellow } from 'colorette';
 import { performance } from 'perf_hooks';
@@ -20,9 +21,13 @@ import {
   Config,
   Oas3Definition,
   Oas2Definition,
+  RedoclyClient,
 } from '@redocly/openapi-core';
-import { Totals, outputExtensions, Entrypoint, ConfigApis } from './types';
+import { Totals, outputExtensions, Entrypoint, ConfigApis, CommandOptions } from './types';
 import { isEmptyObject } from '@redocly/openapi-core/lib/utils';
+import { Arguments } from 'yargs';
+import { version } from './update-version-notifier';
+import { DESTINATION_REGEX } from './commands/push';
 
 export async function getFallbackApisOrExit(
   argsApis: string[] | undefined,
@@ -39,14 +44,10 @@ export async function getFallbackApisOrExit(
   if (isNotEmptyArray(filteredInvalidEntrypoints)) {
     for (const { path } of filteredInvalidEntrypoints) {
       process.stderr.write(
-        yellow(
-          `\n ${relative(process.cwd(), path)} ${red(
-            `does not exist or is invalid. Please provide a valid path. \n\n`
-          )}`
-        )
+        yellow(`\n${relative(process.cwd(), path)} ${red(`does not exist or is invalid.\n\n`)}`)
       );
     }
-    process.exit(1);
+    exitWithError('Please provide a valid path.');
   }
   return res;
 }
@@ -227,30 +228,30 @@ export function pluralize(label: string, num: number) {
 
 export function handleError(e: Error, ref: string) {
   switch (e.constructor) {
+    case HandledError: {
+      throw e;
+    }
     case ResolveError:
       return exitWithError(`Failed to resolve api definition at ${ref}:\n\n  - ${e.message}.`);
     case YamlParseError:
       return exitWithError(`Failed to parse api definition at ${ref}:\n\n  - ${e.message}.`);
     // TODO: codeframe
     case CircularJSONNotSupportedError: {
-      process.stderr.write(
-        red(`Detected circular reference which can't be converted to JSON.\n`) +
-          `Try to use ${blue('yaml')} output or remove ${blue('--dereferenced')}.\n\n`
+      return exitWithError(
+        `Detected circular reference which can't be converted to JSON.\n` +
+          `Try to use ${blue('yaml')} output or remove ${blue('--dereferenced')}.`
       );
-      return process.exit(1);
     }
     case SyntaxError:
       return exitWithError(`Syntax error: ${e.message} ${e.stack?.split('\n\n')?.[0]}`);
     default: {
-      process.stderr.write(
-        red(`Something went wrong when processing ${ref}:\n\n  - ${e.message}.\n\n`)
-      );
-      process.exit(1);
-      throw e;
+      exitWithError(`Something went wrong when processing ${ref}:\n\n  - ${e.message}.`);
     }
   }
 }
 
+export class HandledError extends Error {}
+
 export function printLintTotals(totals: Totals, definitionsCount: number) {
   const ignored = totals.ignored
     ? yellow(`${totals.ignored} ${pluralize('problem is', totals.ignored)} explicitly ignored.\n\n`)
@@ -373,7 +374,7 @@ export function printUnusedWarnings(config: StyleguideConfig) {
 
 export function exitWithError(message: string) {
   process.stderr.write(red(message) + '\n\n');
-  process.exit(1);
+  throw new HandledError(message);
 }
 
 /**
@@ -392,12 +393,11 @@ export async function loadConfigAndHandleErrors(
     files?: string[];
     region?: Region;
   } = {}
-): Promise<Config> {
+): Promise<Config | void> {
   try {
     return await loadConfig(options);
   } catch (e) {
     handleError(e, '');
-    return new Config({ apis: {}, styleguide: {} });
   }
 }
 
@@ -479,3 +479,96 @@ export function cleanColors(input: string): string {
   // eslint-disable-next-line no-control-regex
   return input.replace(/\x1b\[\d+m/g, '');
 }
+
+export async function sendTelemetry(
+  argv: Arguments | undefined,
+  exit_code: ExitCode,
+  has_config: boolean | undefined
+): Promise<void> {
+  try {
+    if (!argv) {
+      return;
+    }
+    const {
+      _: [command],
+      $0: _,
+      ...args
+    } = argv;
+    const event_time = new Date().toISOString();
+    const redoclyClient = new RedoclyClient();
+    const node_version = process.version;
+    const logged_in = await redoclyClient.isAuthorizedWithRedoclyByRegion();
+    const data: Analytics = {
+      event: 'cli_command',
+      event_time,
+      logged_in,
+      command,
+      arguments: cleanArgs(args),
+      node_version,
+      version,
+      exit_code,
+      environment: process.env.REDOCLY_ENVIRONMENT,
+      raw_input: cleanRawInput(process.argv.slice(2)),
+      has_config,
+    };
+    await fetch(`https://api.redocly.com/registry/telemetry/cli`, {
+      method: 'POST',
+      headers: {
+        'content-type': 'application/json',
+      },
+      body: JSON.stringify(data),
+    });
+  } catch (err) {
+    // Do nothing.
+  }
+}
+
+export type ExitCode = 0 | 1 | 2;
+
+export type Analytics = {
+  event: string;
+  event_time: string;
+  logged_in: boolean;
+  command: string | number;
+  arguments: Record<string, unknown>;
+  node_version: string;
+  version: string;
+  exit_code: ExitCode;
+  environment?: string;
+  raw_input: string;
+  has_config?: boolean;
+};
+
+function cleanString(value?: string): string | undefined {
+  if (!value) {
+    return value;
+  }
+  if (isAbsoluteUrl(value)) {
+    return value.split('://')[0] + '://***';
+  }
+  if (value.endsWith('.json') || value.endsWith('.yaml') || value.endsWith('.yml')) {
+    return value.replace(/^(.*)\.(yaml|yml|json)$/gi, (_, __, ext) => '***.' + ext);
+  }
+  if (DESTINATION_REGEX.test(value)) {
+    return value.replace(/^@[\w\-\s]+\//, () => '@***/');
+  }
+  return value;
+}
+
+export function cleanArgs(args: CommandOptions) {
+  const result: Record<string, unknown> = {};
+  for (const [key, value] of Object.entries(args)) {
+    if (typeof value === 'string') {
+      result[key] = cleanString(value);
+    } else if (Array.isArray(value)) {
+      result[key] = value.map(cleanString);
+    } else {
+      result[key] = value;
+    }
+  }
+  return result;
+}
+
+export function cleanRawInput(argv: string[]) {
+  return argv.map((entry) => entry.split('=').map(cleanString).join('=')).join(' ');
+}
diff --git a/packages/cli/src/wrapper.ts b/packages/cli/src/wrapper.ts
new file mode 100644
index 00000000..756e0c3a
--- /dev/null
+++ b/packages/cli/src/wrapper.ts
@@ -0,0 +1,42 @@
+import { Config, Region, doesYamlFileExist } from '@redocly/openapi-core';
+import type { Arguments } from 'yargs';
+import { version } from './update-version-notifier';
+import { ExitCode, exitWithError, loadConfigAndHandleErrors, sendTelemetry } from './utils';
+import { lintConfigCallback } from './commands/lint';
+import type { CommandOptions } from './types';
+
+export function commandWrapper<T extends CommandOptions>(
+  commandHandler: (argv: T, config: Config, version: string) => Promise<void>
+) {
+  return async (argv: Arguments<T>) => {
+    let code: ExitCode = 2;
+    let hasConfig;
+    let telemetry;
+    try {
+      if (argv.config && !doesYamlFileExist(argv.config)) {
+        exitWithError('Please, provide valid path to the configuration file');
+      }
+      const config: Config = (await loadConfigAndHandleErrors({
+        configPath: argv.config,
+        customExtends: argv.extends as string[] | undefined,
+        region: argv.region as Region,
+        files: argv.file as string[] | undefined,
+        processRawConfig: lintConfigCallback(argv as T & Record<string, undefined>, version),
+      })) as Config;
+      telemetry = config.telemetry;
+      hasConfig = !config.styleguide.recommendedFallback;
+      code = 1;
+      await commandHandler(argv, config, version);
+      code = 0;
+    } catch (err) {
+      // Do nothing
+    } finally {
+      if (process.env.REDOCLY_TELEMETRY !== 'off' && telemetry !== 'off') {
+        await sendTelemetry(argv, code, hasConfig);
+      }
+      process.once('beforeExit', () => {
+        process.exit(code);
+      });
+    }
+  };
+}
diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts
index b32c719f..b2693682 100755
--- a/packages/core/src/config/config.ts
+++ b/packages/core/src/config/config.ts
@@ -18,6 +18,7 @@ import type {
   ResolvedStyleguideConfig,
   RuleConfig,
   RuleSettings,
+  Telemetry,
   ThemeRawConfig,
 } from './types';
 import { getResolveConfig } from './utils';
@@ -317,6 +318,7 @@ export class Config {
   theme: ThemeRawConfig;
   organization?: string;
   files: string[];
+  telemetry?: Telemetry;
   constructor(public rawConfig: ResolvedConfig, public configFile?: string) {
     this.apis = rawConfig.apis || {};
     this.styleguide = new StyleguideConfig(rawConfig.styleguide || {}, configFile);
@@ -325,5 +327,6 @@ export class Config {
     this.region = rawConfig.region;
     this.organization = rawConfig.organization;
     this.files = rawConfig.files || [];
+    this.telemetry = rawConfig.telemetry;
   }
 }
diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts
index 6a440004..fc4fe560 100644
--- a/packages/core/src/config/types.ts
+++ b/packages/core/src/config/types.ts
@@ -139,6 +139,7 @@ export type ResolveConfig = {
 };
 
 export type Region = 'us' | 'eu';
+export type Telemetry = 'on' | 'off';
 
 export type AccessTokens = { [region in Region]?: string };
 
@@ -171,6 +172,7 @@ export type RawConfig = {
   region?: Region;
   organization?: string;
   files?: string[];
+  telemetry?: Telemetry;
 } & ThemeConfig;
 
 export type FlatApi = Omit<Api, 'styleguide'> &
diff --git a/packages/core/src/redocly/registry-api.ts b/packages/core/src/redocly/registry-api.ts
index 34559832..63ce39a7 100644
--- a/packages/core/src/redocly/registry-api.ts
+++ b/packages/core/src/redocly/registry-api.ts
@@ -8,6 +8,7 @@ import type {
 import type { AccessTokens, Region } from '../config/types';
 import { DEFAULT_REGION, DOMAINS } from '../config/config';
 import { isNotEmptyObject } from '../utils';
+
 const version = require('../../package.json').version;
 
 export class RegistryApi {
diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts
index 83433b75..ec865477 100644
--- a/packages/core/src/types/redocly-yaml.ts
+++ b/packages/core/src/types/redocly-yaml.ts
@@ -172,6 +172,7 @@ const ConfigRoot: NodeType = {
     'features.openapi': 'ConfigReferenceDocs', // deprecated
     'features.mockServer': 'ConfigMockServer', // deprecated
     region: { enum: ['us', 'eu'] },
+    telemetry: { enum: ['on', 'off'] },
     resolve: {
       properties: {
         http: 'ConfigHTTP',


Test patch:
diff --git a/__tests__/split/missing-outDir/snapshot.js b/__tests__/split/missing-outDir/snapshot.js
index 943676c5..98faca1f 100644
--- a/__tests__/split/missing-outDir/snapshot.js
+++ b/__tests__/split/missing-outDir/snapshot.js
@@ -14,6 +14,7 @@ Options:
   --help       Show help.                                              [boolean]
   --outDir     Output directory where files will be saved.   [string] [required]
   --separator  File path separator used while splitting. [string] [default: "_"]
+  --config     Specify path to the config file.                         [string]
 
 Missing required argument: outDir
 
diff --git a/packages/cli/src/__tests__/commands/build-docs.test.ts b/packages/cli/src/__tests__/commands/build-docs.test.ts
index 57c4ea3e..4babe7fc 100644
--- a/packages/cli/src/__tests__/commands/build-docs.test.ts
+++ b/packages/cli/src/__tests__/commands/build-docs.test.ts
@@ -44,16 +44,19 @@ describe('build-docs', () => {
 
   it('should work correctly when calling handlerBuildCommand', async () => {
     const processExitMock = jest.spyOn(process, 'exit').mockImplementation();
-    await handlerBuildCommand({
-      o: '',
-      cdn: false,
-      title: 'test',
-      disableGoogleFont: false,
-      template: '',
-      templateOptions: {},
-      theme: { openapi: {} },
-      api: '../some-path/openapi.yaml',
-    } as BuildDocsArgv);
+    await handlerBuildCommand(
+      {
+        o: '',
+        cdn: false,
+        title: 'test',
+        disableGoogleFont: false,
+        template: '',
+        templateOptions: {},
+        theme: { openapi: {} },
+        api: '../some-path/openapi.yaml',
+      } as BuildDocsArgv,
+      {} as any
+    );
     expect(loadAndBundleSpec).toBeCalledTimes(1);
     expect(getFallbackApisOrExit).toBeCalledTimes(1);
     expect(processExitMock).toBeCalledTimes(0);
diff --git a/packages/cli/src/__tests__/commands/bundle.test.ts b/packages/cli/src/__tests__/commands/bundle.test.ts
index 41b13df5..2cc40086 100644
--- a/packages/cli/src/__tests__/commands/bundle.test.ts
+++ b/packages/cli/src/__tests__/commands/bundle.test.ts
@@ -1,8 +1,10 @@
 import { lint, bundle, getTotals, getMergedConfig } from '@redocly/openapi-core';
 
-import { handleBundle } from '../../commands/bundle';
+import { BundleOptions, handleBundle } from '../../commands/bundle';
 import { handleError } from '../../utils';
+import { commandWrapper } from '../../wrapper';
 import SpyInstance = jest.SpyInstance;
+import { Arguments } from 'yargs';
 
 jest.mock('@redocly/openapi-core');
 jest.mock('../../utils');
@@ -31,14 +33,11 @@ describe('bundle', () => {
   it('bundles definitions w/o linting', async () => {
     const apis = ['foo.yaml', 'bar.yaml'];
 
-    await handleBundle(
-      {
-        apis,
-        ext: 'yaml',
-        format: 'codeframe',
-      },
-      '1.0.0'
-    );
+    await commandWrapper(handleBundle)({
+      apis,
+      ext: 'yaml',
+      format: 'codeframe',
+    } as Arguments<BundleOptions>);
 
     expect(lint).toBeCalledTimes(0);
     expect(bundle).toBeCalledTimes(apis.length);
@@ -47,16 +46,13 @@ describe('bundle', () => {
   it('exits with code 0 when bundles definitions', async () => {
     const apis = ['foo.yaml', 'bar.yaml', 'foobar.yaml'];
 
-    await handleBundle(
-      {
-        apis,
-        ext: 'yaml',
-        format: 'codeframe',
-      },
-      '1.0.0'
-    );
+    await commandWrapper(handleBundle)({
+      apis,
+      ext: 'yaml',
+      format: 'codeframe',
+    } as Arguments<BundleOptions>);
 
-    exitCb?.();
+    await exitCb?.();
     expect(processExitMock).toHaveBeenCalledWith(0);
   });
 
@@ -69,15 +65,12 @@ describe('bundle', () => {
       ignored: 0,
     });
 
-    await handleBundle(
-      {
-        apis,
-        ext: 'yaml',
-        format: 'codeframe',
-        lint: true,
-      },
-      '1.0.0'
-    );
+    await commandWrapper(handleBundle)({
+      apis,
+      ext: 'yaml',
+      format: 'codeframe',
+      lint: true,
+    } as Arguments<BundleOptions>);
 
     expect(lint).toBeCalledTimes(apis.length);
     expect(bundle).toBeCalledTimes(apis.length);
@@ -86,17 +79,14 @@ describe('bundle', () => {
   it('exits with code 0 when bundles definitions w/linting w/o errors', async () => {
     const apis = ['foo.yaml', 'bar.yaml', 'foobar.yaml'];
 
-    await handleBundle(
-      {
-        apis,
-        ext: 'yaml',
-        format: 'codeframe',
-        lint: true,
-      },
-      '1.0.0'
-    );
-
-    exitCb?.();
+    await commandWrapper(handleBundle)({
+      apis,
+      ext: 'yaml',
+      format: 'codeframe',
+      lint: true,
+    } as Arguments<BundleOptions>);
+
+    await exitCb?.();
     expect(processExitMock).toHaveBeenCalledWith(0);
   });
 
@@ -109,18 +99,15 @@ describe('bundle', () => {
       ignored: 0,
     });
 
-    await handleBundle(
-      {
-        apis,
-        ext: 'yaml',
-        format: 'codeframe',
-        lint: true,
-      },
-      '1.0.0'
-    );
+    await commandWrapper(handleBundle)({
+      apis,
+      ext: 'yaml',
+      format: 'codeframe',
+      lint: true,
+    } as Arguments<BundleOptions>);
 
     expect(lint).toBeCalledTimes(apis.length);
-    exitCb?.();
+    await exitCb?.();
     expect(processExitMock).toHaveBeenCalledWith(1);
   });
 
@@ -131,15 +118,12 @@ describe('bundle', () => {
       throw new Error('Invalid definition');
     });
 
-    await handleBundle(
-      {
-        apis,
-        ext: 'json',
-        format: 'codeframe',
-        lint: false,
-      },
-      '1.0.0'
-    );
+    await commandWrapper(handleBundle)({
+      apis,
+      ext: 'json',
+      format: 'codeframe',
+      lint: false,
+    } as Arguments<BundleOptions>);
 
     expect(handleError).toHaveBeenCalledTimes(1);
     expect(handleError).toHaveBeenCalledWith(new Error('Invalid definition'), 'invalid.json');
@@ -154,15 +138,12 @@ describe('bundle', () => {
       ignored: 0,
     });
 
-    await handleBundle(
-      {
-        apis,
-        ext: 'yaml',
-        format: 'codeframe',
-        lint: false,
-      },
-      '1.0.0'
-    );
+    await commandWrapper(handleBundle)({
+      apis,
+      ext: 'yaml',
+      format: 'codeframe',
+      lint: false,
+    } as Arguments<BundleOptions>);
 
     expect(handleError).toHaveBeenCalledTimes(0);
   });
diff --git a/packages/cli/src/__tests__/commands/join.test.ts b/packages/cli/src/__tests__/commands/join.test.ts
index 895a953b..1787d690 100644
--- a/packages/cli/src/__tests__/commands/join.test.ts
+++ b/packages/cli/src/__tests__/commands/join.test.ts
@@ -3,6 +3,7 @@ import { exitWithError, writeYaml } from '../../utils';
 import { yellow } from 'colorette';
 import { detectOpenAPI } from '@redocly/openapi-core';
 import { loadConfig } from '../../__mocks__/@redocly/openapi-core';
+import { ConfigFixture } from '../fixtures/config';
 
 jest.mock('../../utils');
 jest.mock('colorette');
@@ -12,7 +13,7 @@ describe('handleJoin fails', () => {
   colloreteYellowMock.mockImplementation((string: string) => string);
 
   it('should call exitWithError because only one entrypoint', async () => {
-    await handleJoin({ apis: ['first.yaml'] }, 'cli-version');
+    await handleJoin({ apis: ['first.yaml'] }, {} as any, 'cli-version');
     expect(exitWithError).toHaveBeenCalledWith(`At least 2 apis should be provided. \n\n`);
   });
 
@@ -24,6 +25,7 @@ describe('handleJoin fails', () => {
         'without-x-tag-groups': true,
         'prefix-tags-with-filename': true,
       },
+      {} as any,
       'cli-version'
     );
 
@@ -39,6 +41,7 @@ describe('handleJoin fails', () => {
         'without-x-tag-groups': true,
         'prefix-tags-with-filename': true,
       },
+      {} as any,
       'cli-version'
     );
 
@@ -52,6 +55,7 @@ describe('handleJoin fails', () => {
       {
         apis: ['first.yaml', 'second.yaml'],
       },
+      ConfigFixture as any,
       'cli-version'
     );
     expect(exitWithError).toHaveBeenCalledWith('Only OpenAPI 3 is supported: undefined \n\n');
@@ -63,6 +67,7 @@ describe('handleJoin fails', () => {
       {
         apis: ['first.yaml', 'second.yaml'],
       },
+      ConfigFixture as any,
       'cli-version'
     );
 
@@ -76,6 +81,7 @@ describe('handleJoin fails', () => {
         apis: ['first.yaml', 'second.yaml'],
         output: 'output.yml',
       },
+      ConfigFixture as any,
       'cli-version'
     );
 
@@ -88,6 +94,7 @@ describe('handleJoin fails', () => {
       {
         apis: ['first.yaml', 'second.yaml'],
       },
+      ConfigFixture as any,
       'cli-version'
     );
 
@@ -104,6 +111,7 @@ describe('handleJoin fails', () => {
         decorate: true,
         preprocess: true,
       },
+      ConfigFixture as any,
       'cli-version'
     );
 
diff --git a/packages/cli/src/__tests__/commands/lint.test.ts b/packages/cli/src/__tests__/commands/lint.test.ts
index 1149935d..3198bc9f 100644
--- a/packages/cli/src/__tests__/commands/lint.test.ts
+++ b/packages/cli/src/__tests__/commands/lint.test.ts
@@ -17,18 +17,23 @@ import {
 } from '../../utils';
 import { ConfigFixture } from '../fixtures/config';
 import { performance } from 'perf_hooks';
+import { commandWrapper } from '../../wrapper';
+import { Arguments } from 'yargs';
+import { blue } from 'colorette';
 
 jest.mock('@redocly/openapi-core');
 jest.mock('../../utils');
 jest.mock('perf_hooks');
 
-const argvMock: LintOptions = {
+jest.mock('../../update-version-notifier', () => ({
+  version: '1.0.0',
+}));
+
+const argvMock = {
   apis: ['openapi.yaml'],
   'lint-config': 'off',
   format: 'codeframe',
-};
-
-const versionMock = '1.0.0';
+} as Arguments<LintOptions>;
 
 describe('handleLint', () => {
   let processExitMock: jest.SpyInstance;
@@ -55,15 +60,14 @@ describe('handleLint', () => {
 
   describe('loadConfig and getEnrtypoints stage', () => {
     it('should fail if config file does not exist', async () => {
-      await handleLint({ ...argvMock, config: 'config.yaml' }, versionMock);
+      await commandWrapper(handleLint)({ ...argvMock, config: 'config.yaml' });
       expect(exitWithError).toHaveBeenCalledWith(
         'Please, provide valid path to the configuration file'
       );
-      expect(loadConfigAndHandleErrors).toHaveBeenCalledTimes(0);
     });
 
     it('should call loadConfigAndHandleErrors and getFallbackApisOrExit', async () => {
-      await handleLint(argvMock, versionMock);
+      await commandWrapper(handleLint)(argvMock);
       expect(loadConfigAndHandleErrors).toHaveBeenCalledWith({
         configPath: undefined,
         customExtends: undefined,
@@ -73,10 +77,11 @@ describe('handleLint', () => {
     });
 
     it('should call loadConfig with args if such exist', async () => {
-      await handleLint(
-        { ...argvMock, config: 'redocly.yaml', extends: ['some/path'] },
-        versionMock
-      );
+      await commandWrapper(handleLint)({
+        ...argvMock,
+        config: 'redocly.yaml',
+        extends: ['some/path'],
+      });
       expect(loadConfigAndHandleErrors).toHaveBeenCalledWith({
         configPath: 'redocly.yaml',
         customExtends: ['some/path'],
@@ -85,25 +90,25 @@ describe('handleLint', () => {
     });
 
     it('should call mergedConfig with clear ignore if `generate-ignore-file` argv', async () => {
-      await handleLint({ ...argvMock, 'generate-ignore-file': true }, versionMock);
+      await commandWrapper(handleLint)({ ...argvMock, 'generate-ignore-file': true });
       expect(getMergedConfigMock).toHaveBeenCalled();
     });
 
     it('should check if ruleset exist', async () => {
-      await handleLint(argvMock, versionMock);
+      await commandWrapper(handleLint)(argvMock);
       expect(checkIfRulesetExist).toHaveBeenCalledTimes(1);
     });
 
     it('should fail if apis not provided', async () => {
-      await handleLint({ ...argvMock, apis: [] }, versionMock);
+      await commandWrapper(handleLint)({ ...argvMock, apis: [] });
       expect(getFallbackApisOrExit).toHaveBeenCalledTimes(1);
       expect(exitWithError).toHaveBeenCalledWith('No APIs were provided');
     });
   });
 
-  describe('loop through entrypints and lint stage', () => {
+  describe('loop through entrypoints and lint stage', () => {
     it('should call getMergedConfig and lint ', async () => {
-      await handleLint(argvMock, versionMock);
+      await commandWrapper(handleLint)(argvMock);
       expect(performance.now).toHaveBeenCalled();
       expect(getMergedConfigMock).toHaveBeenCalled();
       expect(lint).toHaveBeenCalled();
@@ -111,56 +116,75 @@ describe('handleLint', () => {
 
     it('should call skipRules,skipPreprocessors and addIgnore with argv', async () => {
       (lint as jest.Mock<any, any>).mockResolvedValueOnce(['problem']);
-      await handleLint(
-        {
-          ...argvMock,
-          'skip-preprocessor': ['preprocessor'],
-          'skip-rule': ['rule'],
-          'generate-ignore-file': true,
-        },
-        versionMock
-      );
+      await commandWrapper(handleLint)({
+        ...argvMock,
+        'skip-preprocessor': ['preprocessor'],
+        'skip-rule': ['rule'],
+        'generate-ignore-file': true,
+      });
       expect(ConfigFixture.styleguide.skipRules).toHaveBeenCalledWith(['rule']);
       expect(ConfigFixture.styleguide.skipPreprocessors).toHaveBeenCalledWith(['preprocessor']);
     });
 
     it('should call formatProblems and getExecutionTime with argv', async () => {
       (lint as jest.Mock<any, any>).mockResolvedValueOnce(['problem']);
-      await handleLint({ ...argvMock, 'max-problems': 2, format: 'stylish' }, versionMock);
+      await commandWrapper(handleLint)({ ...argvMock, 'max-problems': 2, format: 'stylish' });
       expect(getTotals).toHaveBeenCalledWith(['problem']);
       expect(formatProblems).toHaveBeenCalledWith(['problem'], {
         format: 'stylish',
         maxProblems: 2,
         totals: { errors: 0 },
-        version: versionMock,
+        version: '1.0.0',
       });
       expect(getExecutionTime).toHaveBeenCalledWith(42);
     });
 
     it('should catch error in handleError if something fails', async () => {
       (lint as jest.Mock<any, any>).mockRejectedValueOnce('error');
-      await handleLint(argvMock, versionMock);
+      await commandWrapper(handleLint)(argvMock);
       expect(handleError).toHaveBeenCalledWith('error', 'openapi.yaml');
     });
   });
 
   describe('erros and warning handle after lint stage', () => {
     it('should call printLintTotals and printLintTotals', async () => {
-      await handleLint(argvMock, versionMock);
+      await commandWrapper(handleLint)(argvMock);
       expect(printUnusedWarnings).toHaveBeenCalled();
     });
 
     it('should call exit with 0 if no errors', async () => {
-      await handleLint(argvMock, versionMock);
-      exitCb?.();
+      (loadConfigAndHandleErrors as jest.Mock).mockImplementation(() => {
+        return { ...ConfigFixture };
+      });
+      await commandWrapper(handleLint)(argvMock);
+      await exitCb?.();
       expect(processExitMock).toHaveBeenCalledWith(0);
     });
 
-    it('should exit with 1 if tootals error > 0', async () => {
+    it('should exit with 1 if total errors > 0', async () => {
       (getTotals as jest.Mock<any, any>).mockReturnValueOnce({ errors: 1 });
-      await handleLint(argvMock, versionMock);
-      exitCb?.();
+      await commandWrapper(handleLint)(argvMock);
+      await exitCb?.();
       expect(processExitMock).toHaveBeenCalledWith(1);
     });
+
+    it('should use recommended fallback if no config', async () => {
+      (getMergedConfig as jest.Mock).mockImplementation(() => {
+        return {
+          styleguide: {
+            recommendedFallback: true,
+            rules: {},
+            skipRules: jest.fn(),
+            skipPreprocessors: jest.fn(),
+          },
+        };
+      });
+      await commandWrapper(handleLint)(argvMock);
+      expect(process.stderr.write).toHaveBeenCalledWith(
+        `No configurations were provided -- using built in ${blue(
+          'recommended'
+        )} configuration by default.\n\n`
+      );
+    });
   });
 });
diff --git a/packages/cli/src/__tests__/commands/push-region.test.ts b/packages/cli/src/__tests__/commands/push-region.test.ts
index 5e7e7d1d..3217f900 100644
--- a/packages/cli/src/__tests__/commands/push-region.test.ts
+++ b/packages/cli/src/__tests__/commands/push-region.test.ts
@@ -1,6 +1,7 @@
 import { getMergedConfig } from '@redocly/openapi-core';
 import { handlePush } from '../../commands/push';
 import { promptClientToken } from '../../commands/login';
+import { ConfigFixture } from '../fixtures/config';
 
 jest.mock('fs');
 jest.mock('node-fetch', () => ({
@@ -27,24 +28,30 @@ describe('push-with-region', () => {
 
   it('should call login with default domain when region is US', async () => {
     redoclyClient.domain = 'redoc.ly';
-    await handlePush({
-      upsert: true,
-      api: 'spec.json',
-      destination: '@org/my-api@1.0.0',
-      branchName: 'test',
-    });
+    await handlePush(
+      {
+        upsert: true,
+        api: 'spec.json',
+        destination: '@org/my-api@1.0.0',
+        branchName: 'test',
+      },
+      ConfigFixture as any
+    );
     expect(mockPromptClientToken).toBeCalledTimes(1);
     expect(mockPromptClientToken).toHaveBeenCalledWith(redoclyClient.domain);
   });
 
   it('should call login with EU domain when region is EU', async () => {
     redoclyClient.domain = 'eu.redocly.com';
-    await handlePush({
-      upsert: true,
-      api: 'spec.json',
-      destination: '@org/my-api@1.0.0',
-      branchName: 'test',
-    });
+    await handlePush(
+      {
+        upsert: true,
+        api: 'spec.json',
+        destination: '@org/my-api@1.0.0',
+        branchName: 'test',
+      },
+      ConfigFixture as any
+    );
     expect(mockPromptClientToken).toBeCalledTimes(1);
     expect(mockPromptClientToken).toHaveBeenCalledWith(redoclyClient.domain);
   });
diff --git a/packages/cli/src/__tests__/commands/push.test.ts b/packages/cli/src/__tests__/commands/push.test.ts
index 691a7e52..6badbedc 100644
--- a/packages/cli/src/__tests__/commands/push.test.ts
+++ b/packages/cli/src/__tests__/commands/push.test.ts
@@ -1,6 +1,6 @@
 import * as fs from 'fs';
 import { Config, getMergedConfig } from '@redocly/openapi-core';
-import { exitWithError, loadConfigAndHandleErrors } from '../../utils';
+import { exitWithError } from '../../utils';
 import { getApiRoot, getDestinationProps, handlePush, transformPush } from '../../commands/push';
 import { ConfigFixture } from '../fixtures/config';
 import { yellow } from 'colorette';
@@ -25,15 +25,18 @@ describe('push', () => {
   });
 
   it('pushes definition', async () => {
-    await handlePush({
-      upsert: true,
-      api: 'spec.json',
-      destination: '@org/my-api@1.0.0',
-      branchName: 'test',
-      public: true,
-      'batch-id': '123',
-      'batch-size': 2,
-    });
+    await handlePush(
+      {
+        upsert: true,
+        api: 'spec.json',
+        destination: '@org/my-api@1.0.0',
+        branchName: 'test',
+        public: true,
+        'batch-id': '123',
+        'batch-size': 2,
+      },
+      ConfigFixture as any
+    );
 
     expect(redoclyClient.registryApi.prepareFileUpload).toBeCalledTimes(1);
     expect(redoclyClient.registryApi.pushApi).toBeCalledTimes(1);
@@ -52,50 +55,61 @@ describe('push', () => {
   });
 
   it('fails if batchId value is an empty string', async () => {
-    await handlePush({
-      upsert: true,
-      api: 'spec.json',
-      destination: '@org/my-api@1.0.0',
-      branchName: 'test',
-      public: true,
-      'batch-id': ' ',
-      'batch-size': 2,
-    });
+    await handlePush(
+      {
+        upsert: true,
+        api: 'spec.json',
+        destination: '@org/my-api@1.0.0',
+        branchName: 'test',
+        public: true,
+        'batch-id': ' ',
+        'batch-size': 2,
+      },
+      ConfigFixture as any
+    );
 
     expect(exitWithError).toBeCalledTimes(1);
   });
 
   it('fails if batchSize value is less than 2', async () => {
-    await handlePush({
-      upsert: true,
-      api: 'spec.json',
-      destination: '@org/my-api@1.0.0',
-      branchName: 'test',
-      public: true,
-      'batch-id': '123',
-      'batch-size': 1,
-    });
+    await handlePush(
+      {
+        upsert: true,
+        api: 'spec.json',
+        destination: '@org/my-api@1.0.0',
+        branchName: 'test',
+        public: true,
+        'batch-id': '123',
+        'batch-size': 1,
+      },
+      ConfigFixture as any
+    );
 
     expect(exitWithError).toBeCalledTimes(1);
   });
 
   it('push with --files', async () => {
-    (loadConfigAndHandleErrors as jest.Mock).mockImplementation(({ files }) => {
-      return { ...ConfigFixture, files };
-    });
+    // (loadConfigAndHandleErrors as jest.Mock).mockImplementation(({ files }) => {
+    //   return { ...ConfigFixture, files };
+    // });
+
+    const mockConfig = { ...ConfigFixture, files: ['./resouces/1.md', './resouces/2.md'] } as any;
 
     //@ts-ignore
     fs.statSync.mockImplementation(() => {
       return { isDirectory: () => false, size: 10 };
     });
 
-    await handlePush({
-      upsert: true,
-      api: 'spec.json',
-      destination: '@org/my-api@1.0.0',
-      public: true,
-      files: ['./resouces/1.md', './resouces/2.md'],
-    });
+    await handlePush(
+      {
+        upsert: true,
+        api: 'spec.json',
+        destination: '@org/my-api@1.0.0',
+        public: true,
+        files: ['./resouces/1.md', './resouces/2.md'],
+      },
+      mockConfig
+    );
 
     expect(redoclyClient.registryApi.pushApi).toHaveBeenLastCalledWith({
       filePaths: ['filePath', 'filePath', 'filePath'],
@@ -110,15 +124,18 @@ describe('push', () => {
   });
 
   it('push should fail if organization not provided', async () => {
-    await handlePush({
-      upsert: true,
-      api: 'spec.json',
-      destination: 'test@v1',
-      branchName: 'test',
-      public: true,
-      'batch-id': '123',
-      'batch-size': 2,
-    });
+    await handlePush(
+      {
+        upsert: true,
+        api: 'spec.json',
+        destination: 'test@v1',
+        branchName: 'test',
+        public: true,
+        'batch-id': '123',
+        'batch-size': 2,
+      },
+      ConfigFixture as any
+    );
 
     expect(exitWithError).toBeCalledTimes(1);
     expect(exitWithError).toBeCalledWith(
@@ -129,18 +146,19 @@ describe('push', () => {
   });
 
   it('push should work with organization in config', async () => {
-    (loadConfigAndHandleErrors as jest.Mock).mockImplementation(() => {
-      return { ...ConfigFixture, organization: 'test_org' };
-    });
-    await handlePush({
-      upsert: true,
-      api: 'spec.json',
-      destination: 'my-api@1.0.0',
-      branchName: 'test',
-      public: true,
-      'batch-id': '123',
-      'batch-size': 2,
-    });
+    const mockConfig = { ...ConfigFixture, organization: 'test_org' } as any;
+    await handlePush(
+      {
+        upsert: true,
+        api: 'spec.json',
+        destination: 'my-api@1.0.0',
+        branchName: 'test',
+        public: true,
+        'batch-id': '123',
+        'batch-size': 2,
+      },
+      mockConfig
+    );
 
     expect(redoclyClient.registryApi.pushApi).toBeCalledTimes(1);
     expect(redoclyClient.registryApi.pushApi).toHaveBeenLastCalledWith({
@@ -158,36 +176,40 @@ describe('push', () => {
   });
 
   it('push should work if destination not provided and api in config is provided', async () => {
-    (loadConfigAndHandleErrors as jest.Mock).mockImplementation(() => {
-      return {
-        ...ConfigFixture,
-        organization: 'test_org',
-        apis: { 'my-api@1.0.0': { root: 'path' } },
-      };
-    });
-    await handlePush({
-      upsert: true,
-      api: 'spec.json',
-      branchName: 'test',
-      public: true,
-      'batch-id': '123',
-      'batch-size': 2,
-    });
+    const mockConfig = {
+      ...ConfigFixture,
+      organization: 'test_org',
+      apis: { 'my-api@1.0.0': { root: 'path' } },
+    } as any;
+
+    await handlePush(
+      {
+        upsert: true,
+        api: 'spec.json',
+        branchName: 'test',
+        public: true,
+        'batch-id': '123',
+        'batch-size': 2,
+      },
+      mockConfig
+    );
 
     expect(redoclyClient.registryApi.pushApi).toBeCalledTimes(1);
   });
 
   it('push should fail if destination and apis not provided', async () => {
-    (loadConfigAndHandleErrors as jest.Mock).mockImplementation(() => {
-      return { organization: 'test_org', apis: {} };
-    });
-    await handlePush({
-      upsert: true,
-      branchName: 'test',
-      public: true,
-      'batch-id': '123',
-      'batch-size': 2,
-    });
+    const mockConfig = { organization: 'test_org', apis: {} } as any;
+
+    await handlePush(
+      {
+        upsert: true,
+        branchName: 'test',
+        public: true,
+        'batch-id': '123',
+        'batch-size': 2,
+      },
+      mockConfig
+    );
 
     expect(exitWithError).toBeCalledTimes(1);
     expect(exitWithError).toHaveBeenLastCalledWith(
@@ -197,21 +219,24 @@ describe('push', () => {
 
   it('push should work and encode name with spaces', async () => {
     const encodeURIComponentSpy = jest.spyOn(global, 'encodeURIComponent');
-    (loadConfigAndHandleErrors as jest.Mock).mockImplementation(() => {
-      return {
-        ...ConfigFixture,
-        organization: 'test_org',
-        apis: { 'my test api@v1': { root: 'path' } },
-      };
-    });
-    await handlePush({
-      upsert: true,
-      destination: 'my test api@v1',
-      branchName: 'test',
-      public: true,
-      'batch-id': '123',
-      'batch-size': 2,
-    });
+
+    const mockConfig = {
+      ...ConfigFixture,
+      organization: 'test_org',
+      apis: { 'my test api@v1': { root: 'path' } },
+    } as any;
+
+    await handlePush(
+      {
+        upsert: true,
+        destination: 'my test api@v1',
+        branchName: 'test',
+        public: true,
+        'batch-id': '123',
+        'batch-size': 2,
+      },
+      mockConfig
+    );
 
     expect(encodeURIComponentSpy).toHaveReturnedWith('my%20test%20api');
     expect(redoclyClient.registryApi.pushApi).toBeCalledTimes(1);
@@ -221,66 +246,96 @@ describe('push', () => {
 describe('transformPush', () => {
   it('should adapt the existing syntax', () => {
     const cb = jest.fn();
-    transformPush(cb)({
-      maybeApiOrDestination: 'openapi.yaml',
-      maybeDestination: '@testing_org/main@v1',
-    });
-    expect(cb).toBeCalledWith({
-      api: 'openapi.yaml',
-      destination: '@testing_org/main@v1',
-    });
+    transformPush(cb)(
+      {
+        maybeApiOrDestination: 'openapi.yaml',
+        maybeDestination: '@testing_org/main@v1',
+      },
+      {} as any
+    );
+    expect(cb).toBeCalledWith(
+      {
+        api: 'openapi.yaml',
+        destination: '@testing_org/main@v1',
+      },
+      {}
+    );
   });
   it('should adapt the existing syntax (including branchName)', () => {
     const cb = jest.fn();
-    transformPush(cb)({
-      maybeApiOrDestination: 'openapi.yaml',
-      maybeDestination: '@testing_org/main@v1',
-      maybeBranchName: 'other',
-    });
-    expect(cb).toBeCalledWith({
-      api: 'openapi.yaml',
-      destination: '@testing_org/main@v1',
-      branchName: 'other',
-    });
+    transformPush(cb)(
+      {
+        maybeApiOrDestination: 'openapi.yaml',
+        maybeDestination: '@testing_org/main@v1',
+        maybeBranchName: 'other',
+      },
+      {} as any
+    );
+    expect(cb).toBeCalledWith(
+      {
+        api: 'openapi.yaml',
+        destination: '@testing_org/main@v1',
+        branchName: 'other',
+      },
+      {}
+    );
   });
   it('should use --branch option firstly', () => {
     const cb = jest.fn();
-    transformPush(cb)({
-      maybeApiOrDestination: 'openapi.yaml',
-      maybeDestination: '@testing_org/main@v1',
-      maybeBranchName: 'other',
-      branch: 'priority-branch',
-    });
-    expect(cb).toBeCalledWith({
-      api: 'openapi.yaml',
-      destination: '@testing_org/main@v1',
-      branchName: 'priority-branch',
-    });
+    transformPush(cb)(
+      {
+        maybeApiOrDestination: 'openapi.yaml',
+        maybeDestination: '@testing_org/main@v1',
+        maybeBranchName: 'other',
+        branch: 'priority-branch',
+      },
+      {} as any
+    );
+    expect(cb).toBeCalledWith(
+      {
+        api: 'openapi.yaml',
+        destination: '@testing_org/main@v1',
+        branchName: 'priority-branch',
+      },
+      {}
+    );
   });
   it('should work for a destination only', () => {
     const cb = jest.fn();
-    transformPush(cb)({
-      maybeApiOrDestination: '@testing_org/main@v1',
-    });
-    expect(cb).toBeCalledWith({
-      destination: '@testing_org/main@v1',
-    });
+    transformPush(cb)(
+      {
+        maybeApiOrDestination: '@testing_org/main@v1',
+      },
+      {} as any
+    );
+    expect(cb).toBeCalledWith(
+      {
+        destination: '@testing_org/main@v1',
+      },
+      {}
+    );
   });
   it('should accept aliases for the old syntax', () => {
     const cb = jest.fn();
-    transformPush(cb)({
-      maybeApiOrDestination: 'alias',
-      maybeDestination: '@testing_org/main@v1',
-    });
-    expect(cb).toBeCalledWith({
-      destination: '@testing_org/main@v1',
-      api: 'alias',
-    });
+    transformPush(cb)(
+      {
+        maybeApiOrDestination: 'alias',
+        maybeDestination: '@testing_org/main@v1',
+      },
+      {} as any
+    );
+    expect(cb).toBeCalledWith(
+      {
+        destination: '@testing_org/main@v1',
+        api: 'alias',
+      },
+      {}
+    );
   });
   it('should accept no arguments at all', () => {
     const cb = jest.fn();
-    transformPush(cb)({});
-    expect(cb).toBeCalledWith({});
+    transformPush(cb)({}, {} as any);
+    expect(cb).toBeCalledWith({}, {});
   });
 });
 
diff --git a/packages/cli/src/__tests__/utils.test.ts b/packages/cli/src/__tests__/utils.test.ts
index 86acb613..c455621a 100644
--- a/packages/cli/src/__tests__/utils.test.ts
+++ b/packages/cli/src/__tests__/utils.test.ts
@@ -9,6 +9,9 @@ import {
   CircularJSONNotSupportedError,
   sortTopLevelKeysForOas,
   cleanColors,
+  HandledError,
+  cleanArgs,
+  cleanRawInput,
 } from '../utils';
 import {
   ResolvedApi,
@@ -24,6 +27,7 @@ import * as process from 'process';
 
 jest.mock('os');
 jest.mock('colorette');
+
 jest.mock('fs');
 
 describe('isSubdir', () => {
@@ -150,23 +154,34 @@ describe('getFallbackApisOrExit', () => {
     jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
     jest.spyOn(process, 'exit').mockImplementation();
   });
+  afterEach(() => {
+    jest.clearAllMocks();
+  });
 
   it('should exit with error because no path provided', async () => {
     const apisConfig = {
       apis: {},
     };
-    await getFallbackApisOrExit([''], apisConfig);
-    expect(process.exit).toHaveBeenCalledWith(1);
+    expect.assertions(1);
+    try {
+      await getFallbackApisOrExit([''], apisConfig);
+    } catch (e) {
+      expect(e.message).toEqual('Path cannot be empty.');
+    }
   });
 
   it('should error if file from config do not exist', async () => {
     (existsSync as jest.Mock<any, any>).mockImplementationOnce(() => false);
-    await getFallbackApisOrExit(undefined, config);
-
-    expect(process.stderr.write).toHaveBeenCalledWith(
-      '\n someFile.yaml does not exist or is invalid. Please provide a valid path. \n\n'
-    );
-    expect(process.exit).toHaveBeenCalledWith(1);
+    expect.assertions(3);
+    try {
+      await getFallbackApisOrExit(undefined, config);
+    } catch (e) {
+      expect(process.stderr.write).toHaveBeenCalledWith(
+        '\nsomeFile.yaml does not exist or is invalid.\n\n'
+      );
+      expect(process.stderr.write).toHaveBeenCalledWith('Please provide a valid path.\n\n');
+      expect(e.message).toEqual('Please provide a valid path.');
+    }
   });
 
   it('should return valid array with results if such file exist', async () => {
@@ -189,12 +204,17 @@ describe('getFallbackApisOrExit', () => {
       apis: {},
     };
     (existsSync as jest.Mock<any, any>).mockImplementationOnce(() => false);
-    await getFallbackApisOrExit(['someFile.yaml'], apisConfig);
+    expect.assertions(3);
 
-    expect(process.stderr.write).toHaveBeenCalledWith(
-      '\n someFile.yaml does not exist or is invalid. Please provide a valid path. \n\n'
-    );
-    expect(process.exit).toHaveBeenCalledWith(1);
+    try {
+      await getFallbackApisOrExit(['someFile.yaml'], apisConfig);
+    } catch (e) {
+      expect(process.stderr.write).toHaveBeenCalledWith(
+        '\nsomeFile.yaml does not exist or is invalid.\n\n'
+      );
+      expect(process.stderr.write).toHaveBeenCalledWith('Please provide a valid path.\n\n');
+      expect(e.message).toEqual('Please provide a valid path.');
+    }
   });
 
   it('should exit with error in case if invalid 2 path provided as args', async () => {
@@ -202,12 +222,16 @@ describe('getFallbackApisOrExit', () => {
       apis: {},
     };
     (existsSync as jest.Mock<any, any>).mockImplementationOnce(() => false);
-    await getFallbackApisOrExit(['someFile.yaml', 'someFile2.yaml'], apisConfig);
-
-    expect(process.stderr.write).lastCalledWith(
-      '\n someFile2.yaml does not exist or is invalid. Please provide a valid path. \n\n'
-    );
-    expect(process.exit).toHaveBeenCalledWith(1);
+    expect.assertions(3);
+    try {
+      await getFallbackApisOrExit(['someFile.yaml', 'someFile2.yaml'], apisConfig);
+    } catch (e) {
+      expect(process.stderr.write).toHaveBeenCalledWith(
+        '\nsomeFile.yaml does not exist or is invalid.\n\n'
+      );
+      expect(process.stderr.write).toHaveBeenCalledWith('Please provide a valid path.\n\n');
+      expect(e.message).toEqual('Please provide a valid path.');
+    }
   });
 
   it('should exit with error if only one file exist ', async () => {
@@ -220,14 +244,23 @@ describe('getFallbackApisOrExit', () => {
     };
     const configStub = { apis: apisStub };
 
-    (existsSync as jest.Mock<any, any>).mockImplementationOnce((path) => path === 'someFile.yaml');
+    const existSyncMock = (existsSync as jest.Mock<any, any>).mockImplementation((path) =>
+      path.endsWith('someFile.yaml')
+    );
 
-    await getFallbackApisOrExit(undefined, configStub);
+    expect.assertions(4);
 
-    expect(process.stderr.write).toBeCalledWith(
-      '\n notExist.yaml does not exist or is invalid. Please provide a valid path. \n\n'
-    );
-    expect(process.exit).toHaveBeenCalledWith(1);
+    try {
+      await getFallbackApisOrExit(undefined, configStub);
+    } catch (e) {
+      expect(process.stderr.write).toHaveBeenCalledWith(
+        '\nnotExist.yaml does not exist or is invalid.\n\n'
+      );
+      expect(process.stderr.write).toHaveBeenCalledWith('Please provide a valid path.\n\n');
+      expect(process.stderr.write).toHaveBeenCalledTimes(2);
+      expect(e.message).toEqual('Please provide a valid path.');
+    }
+    existSyncMock.mockClear();
   });
 
   it('should work ok if it is url passed', async () => {
@@ -245,7 +278,6 @@ describe('getFallbackApisOrExit', () => {
     const result = await getFallbackApisOrExit(undefined, apisConfig);
 
     expect(process.stderr.write).toHaveBeenCalledTimes(0);
-    expect(process.exit).toHaveBeenCalledTimes(0);
     expect(result).toStrictEqual([
       {
         alias: 'main',
@@ -356,11 +388,13 @@ describe('handleErrors', () => {
   const ref = 'openapi/test.yaml';
 
   const redColoretteMocks = red as jest.Mock<any, any>;
+  const blueColoretteMocks = blue as jest.Mock<any, any>;
 
   beforeEach(() => {
     jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
     jest.spyOn(process, 'exit').mockImplementation((code) => code as never);
     redColoretteMocks.mockImplementation((text) => text);
+    blueColoretteMocks.mockImplementation((text) => text);
   });
 
   afterEach(() => {
@@ -369,9 +403,8 @@ describe('handleErrors', () => {
 
   it('should handle ResolveError', () => {
     const resolveError = new ResolveError(new Error('File not found'));
-    handleError(resolveError, ref);
+    expect(() => handleError(resolveError, ref)).toThrowError(HandledError);
     expect(redColoretteMocks).toHaveBeenCalledTimes(1);
-    expect(process.exit).toHaveBeenCalledWith(1);
     expect(process.stderr.write).toHaveBeenCalledWith(
       `Failed to resolve api definition at openapi/test.yaml:\n\n  - File not found.\n\n`
     );
@@ -379,9 +412,8 @@ describe('handleErrors', () => {
 
   it('should handle YamlParseError', () => {
     const yamlParseError = new YamlParseError(new Error('Invalid yaml'), {} as any);
-    handleError(yamlParseError, ref);
+    expect(() => handleError(yamlParseError, ref)).toThrowError(HandledError);
     expect(redColoretteMocks).toHaveBeenCalledTimes(1);
-    expect(process.exit).toHaveBeenCalledWith(1);
     expect(process.stderr.write).toHaveBeenCalledWith(
       `Failed to parse api definition at openapi/test.yaml:\n\n  - Invalid yaml.\n\n`
     );
@@ -389,8 +421,7 @@ describe('handleErrors', () => {
 
   it('should handle CircularJSONNotSupportedError', () => {
     const circularError = new CircularJSONNotSupportedError(new Error('Circular json'));
-    handleError(circularError, ref);
-    expect(process.exit).toHaveBeenCalledWith(1);
+    expect(() => handleError(circularError, ref)).toThrowError(HandledError);
     expect(process.stderr.write).toHaveBeenCalledWith(
       `Detected circular reference which can't be converted to JSON.\n` +
         `Try to use ${blue('yaml')} output or remove ${blue('--dereferenced')}.\n\n`
@@ -400,12 +431,7 @@ describe('handleErrors', () => {
   it('should handle SyntaxError', () => {
     const testError = new SyntaxError('Unexpected identifier');
     testError.stack = 'test stack';
-    try {
-      handleError(testError, ref);
-    } catch (e) {
-      expect(e).toEqual(testError);
-    }
-    expect(process.exit).toHaveBeenCalledWith(1);
+    expect(() => handleError(testError, ref)).toThrowError(HandledError);
     expect(process.stderr.write).toHaveBeenCalledWith(
       'Syntax error: Unexpected identifier test stack\n\n'
     );
@@ -413,11 +439,7 @@ describe('handleErrors', () => {
 
   it('should throw unknown error', () => {
     const testError = new Error('Test error');
-    try {
-      handleError(testError, ref);
-    } catch (e) {
-      expect(e).toEqual(testError);
-    }
+    expect(() => handleError(testError, ref)).toThrowError(HandledError);
     expect(process.stderr.write).toHaveBeenCalledWith(
       `Something went wrong when processing openapi/test.yaml:\n\n  - Test error.\n\n`
     );
@@ -433,24 +455,24 @@ describe('checkIfRulesetExist', () => {
     jest.clearAllMocks();
   });
 
-  it('should exit if rules not provided', () => {
+  it('should throw an error if rules are not provided', () => {
     const rules = {
       oas2: {},
       oas3_0: {},
       oas3_1: {},
     };
-    checkIfRulesetExist(rules);
-    expect(process.exit).toHaveBeenCalledWith(1);
+    expect(() => checkIfRulesetExist(rules)).toThrowError(
+      '⚠️ No rules were configured. Learn how to configure rules: https://redocly.com/docs/cli/rules/'
+    );
   });
 
-  it('should not exit if rules provided', () => {
+  it('should not throw an error if rules are provided', () => {
     const rules = {
       oas2: { 'operation-4xx-response': 'error' },
       oas3_0: {},
       oas3_1: {},
     } as any;
     checkIfRulesetExist(rules);
-    expect(process.exit).not.toHaveBeenCalled();
   });
 });
 
@@ -462,3 +484,49 @@ describe('cleanColors', () => {
     expect(result).not.toMatch(/\x1b\[\d+m/g);
   });
 });
+
+describe('cleanArgs', () => {
+  beforeEach(() => {
+    // @ts-ignore
+    isAbsoluteUrl = jest.requireActual('@redocly/openapi-core').isAbsoluteUrl;
+  });
+  it('should remove potentially sensitive data from args', () => {
+    const testArgs = {
+      config: 'some-folder/redocly.yaml',
+      apis: ['main@v1', 'openapi.yaml', 'http://some.url/openapi.yaml'],
+      format: 'codeframe',
+    };
+    expect(cleanArgs(testArgs)).toEqual({
+      config: '***.yaml',
+      apis: ['main@v1', '***.yaml', 'http://***'],
+      format: 'codeframe',
+    });
+  });
+  it('should remove potentially sensitive data from a push destination', () => {
+    const testArgs = {
+      destination: '@org/name@version',
+    };
+    expect(cleanArgs(testArgs)).toEqual({
+      destination: '@***/name@version',
+    });
+  });
+});
+
+describe('cleanRawInput', () => {
+  it('should remove  potentially sensitive data from raw CLI input', () => {
+    // @ts-ignore
+    isAbsoluteUrl = jest.requireActual('@redocly/openapi-core').isAbsoluteUrl;
+
+    const rawInput = [
+      'redocly',
+      'lint',
+      'main@v1',
+      'openapi.yaml',
+      'http://some.url/openapi.yaml',
+      '--config=some-folder/redocly.yaml',
+    ];
+    expect(cleanRawInput(rawInput)).toEqual(
+      'redocly lint main@v1 ***.yaml http://*** --config=***.yaml'
+    );
+  });
+});
diff --git a/packages/cli/src/__tests__/wrapper.test.ts b/packages/cli/src/__tests__/wrapper.test.ts
new file mode 100644
index 00000000..553b6a67
--- /dev/null
+++ b/packages/cli/src/__tests__/wrapper.test.ts
@@ -0,0 +1,43 @@
+import { loadConfigAndHandleErrors, sendTelemetry } from '../utils';
+import * as process from 'process';
+import { commandWrapper } from '../wrapper';
+import { handleLint } from '../commands/lint';
+import nodeFetch from 'node-fetch';
+
+jest.mock('node-fetch');
+jest.mock('../utils', () => ({
+  sendTelemetry: jest.fn(),
+  loadConfigAndHandleErrors: jest.fn(),
+}));
+jest.mock('../commands/lint', () => ({
+  handleLint: jest.fn(),
+  lintConfigCallback: jest.fn(),
+}));
+
+describe('commandWrapper', () => {
+  it('should send telemetry if there is "telemetry: on" in the config', async () => {
+    (loadConfigAndHandleErrors as jest.Mock).mockImplementation(() => {
+      return { telemetry: 'on', styleguide: { recommendedFallback: true } };
+    });
+    process.env.REDOCLY_TELEMETRY = 'on';
+
+    const wrappedHandler = commandWrapper(handleLint);
+    await wrappedHandler({} as any);
+    expect(handleLint).toHaveBeenCalledTimes(1);
+    expect(sendTelemetry).toHaveBeenCalledTimes(1);
+    expect(sendTelemetry).toHaveBeenCalledWith({}, 0, false);
+  });
+
+  it('should NOT send telemetry if there is "telemetry: off" in the config', async () => {
+    (loadConfigAndHandleErrors as jest.Mock).mockImplementation(() => {
+      return { telemetry: 'off', styleguide: { recommendedFallback: true } };
+    });
+    process.env.REDOCLY_TELEMETRY = 'on';
+
+    const wrappedHandler = commandWrapper(handleLint);
+    await wrappedHandler({} as any);
+    expect(handleLint).toHaveBeenCalledTimes(1);
+
+    expect(sendTelemetry).toHaveBeenCalledTimes(0);
+  });
+});
diff --git a/packages/core/src/config/__tests__/config.test.ts b/packages/core/src/config/__tests__/config.test.ts
index 75c0c8d0..98caf874 100644
--- a/packages/core/src/config/__tests__/config.test.ts
+++ b/packages/core/src/config/__tests__/config.test.ts
@@ -22,6 +22,7 @@ const testConfig: Config = {
       },
     },
     organization: 'redocly-test',
+    telemetry: 'on',
     styleguide: {
       rules: { 'operation-summary': 'error', 'no-empty-servers': 'error' },
       plugins: [],
@@ -101,6 +102,7 @@ describe('getMergedConfig', () => {
               "operation-summary": "warn",
             },
           },
+          "telemetry": "on",
           "theme": Object {},
         },
         "region": undefined,
@@ -149,6 +151,7 @@ describe('getMergedConfig', () => {
             },
           },
         },
+        "telemetry": "on",
         "theme": Object {},
       }
     `);
@@ -199,6 +202,7 @@ describe('getMergedConfig', () => {
               "operation-summary": "error",
             },
           },
+          "telemetry": "on",
           "theme": Object {},
         },
         "region": undefined,
@@ -252,6 +256,7 @@ describe('getMergedConfig', () => {
             },
           },
         },
+        "telemetry": "on",
         "theme": Object {},
       }
     `);