@thegetty/quire-cli 1.0.0-rc.40 → 1.0.0-rc.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js CHANGED
@@ -19,6 +19,20 @@ process.removeAllListeners('warning')
19
19
  */
20
20
  process.env.QUIRE_LOG_LEVEL = config.get('logLevel') || 'info'
21
21
 
22
+ /**
23
+ * Set REDUCED_MOTION env var from config before importing CLI modules
24
+ *
25
+ * The reporter reads REDUCED_MOTION to decide whether to use animated
26
+ * spinners or static text output. Setting it here ensures the reporter
27
+ * picks up the config value at module load time.
28
+ * If REDUCED_MOTION is already set in the shell environment, we do not override it.
29
+ *
30
+ * @see lib/reporter/index.js
31
+ */
32
+ if (process.env.REDUCED_MOTION === undefined && config.get('reducedMotion') === true) {
33
+ process.env.REDUCED_MOTION = '1'
34
+ }
35
+
22
36
  /**
23
37
  * Set NO_COLOR env var from config before importing CLI modules
24
38
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@thegetty/quire-cli",
3
3
  "description": "Quire command-line interface",
4
- "version": "1.0.0-rc.40",
4
+ "version": "1.0.0-rc.42",
5
5
  "author": "Getty Digital",
6
6
  "license": "SEE LICENSE IN https://github.com/thegetty/quire/blob/main/LICENSE",
7
7
  "bugs": {
@@ -7,6 +7,7 @@ import {
7
7
  getDefault,
8
8
  formatSettings,
9
9
  } from '#lib/conf/index.js'
10
+ import { UnknownConfigKeyError, UnknownConfigOperationError } from '#src/errors/index.js'
10
11
 
11
12
  /**
12
13
  * Valid operations for the settings command
@@ -77,16 +78,6 @@ Examples:
77
78
  super(SettingsCommand.definition)
78
79
  }
79
80
 
80
- /**
81
- * Log an unknown key error with valid keys hint
82
- *
83
- * @param {string} key - The unknown configuration key
84
- */
85
- #logUnknownKey(key) {
86
- this.logger.error(`Unknown configuration key: ${key}`)
87
- this.logger.info(`Valid keys: ${getValidKeys().join(', ')}`)
88
- }
89
-
90
81
  /**
91
82
  * Get a single configuration value
92
83
  *
@@ -100,8 +91,7 @@ Examples:
100
91
  }
101
92
 
102
93
  if (!isValidKey(key) && !key.startsWith('__internal__')) {
103
- this.#logUnknownKey(key)
104
- return
94
+ throw new UnknownConfigKeyError(key, getValidKeys())
105
95
  }
106
96
 
107
97
  const value = this.config.get(key)
@@ -131,8 +121,7 @@ Examples:
131
121
  }
132
122
 
133
123
  if (!isValidKey(key)) {
134
- this.#logUnknownKey(key)
135
- return
124
+ throw new UnknownConfigKeyError(key, getValidKeys())
136
125
  }
137
126
 
138
127
  const coercedValue = coerceValue(key, value)
@@ -157,8 +146,7 @@ Examples:
157
146
  }
158
147
 
159
148
  if (!isValidKey(key)) {
160
- this.#logUnknownKey(key)
161
- return
149
+ throw new UnknownConfigKeyError(key, getValidKeys())
162
150
  }
163
151
 
164
152
  this.config.delete(key)
@@ -174,8 +162,7 @@ Examples:
174
162
  #reset(key) {
175
163
  if (key) {
176
164
  if (!isValidKey(key)) {
177
- this.#logUnknownKey(key)
178
- return
165
+ throw new UnknownConfigKeyError(key, getValidKeys())
179
166
  }
180
167
  this.config.reset(key)
181
168
  const defaultValue = getDefault(key)
@@ -229,9 +216,7 @@ Examples:
229
216
 
230
217
  // Validate operation if provided
231
218
  if (operation && !OPERATIONS.includes(operation)) {
232
- this.logger.error(`Unknown operation: ${operation}`)
233
- this.logger.info(`Valid operations: ${OPERATIONS.join(', ')}`)
234
- return
219
+ throw new UnknownConfigOperationError(operation, [...OPERATIONS])
235
220
  }
236
221
 
237
222
  // Dispatch to operation handler
@@ -200,7 +200,7 @@ test('conf get should show error when key is missing', async (t) => {
200
200
  t.true(mockLogger.error.calledWith('Usage: quire conf get <key>'))
201
201
  })
202
202
 
203
- test('conf get should show error for unknown key', async (t) => {
203
+ test('conf get should throw UnknownConfigKeyError for unknown key', async (t) => {
204
204
  const { sandbox, mockLogger, mockConfig } = t.context
205
205
 
206
206
  const { default: ConfCommand } = await esmock('./config.js', {}, {
@@ -214,12 +214,55 @@ test('conf get should show error for unknown key', async (t) => {
214
214
  command.logger = mockLogger
215
215
  command.debug = sandbox.stub()
216
216
 
217
- await command.action('get', 'unknownKey', undefined, {})
217
+ const error = await t.throwsAsync(
218
+ () => command.action('get', 'unknownKey', undefined, {}),
219
+ { code: 'UNKNOWN_CONFIG_KEY' }
220
+ )
221
+ t.true(error.message.includes('Unknown configuration key: unknownKey'))
222
+ t.true(error.suggestion.includes('Valid keys:'))
223
+ })
218
224
 
219
- t.true(mockLogger.error.calledOnce)
220
- t.true(mockLogger.error.calledWith('Unknown configuration key: unknownKey'))
221
- t.true(mockLogger.info.calledOnce)
222
- t.true(mockLogger.info.firstCall.args[0].includes('Valid keys:'))
225
+ test('conf get should suggest similar key for close misspelling', async (t) => {
226
+ const { sandbox, mockLogger, mockConfig } = t.context
227
+
228
+ const { default: ConfCommand } = await esmock('./config.js', {}, {
229
+ '#lib/logger/index.js': {
230
+ default: () => mockLogger
231
+ }
232
+ })
233
+
234
+ const command = new ConfCommand()
235
+ command.config = mockConfig
236
+ command.logger = mockLogger
237
+ command.debug = sandbox.stub()
238
+
239
+ const error = await t.throwsAsync(
240
+ () => command.action('get', 'logLeve', undefined, {}),
241
+ { code: 'UNKNOWN_CONFIG_KEY' }
242
+ )
243
+ t.true(error.message.includes('Unknown configuration key: logLeve'))
244
+ t.true(error.suggestion.includes('Did you mean: logLevel?'))
245
+ })
246
+
247
+ test('conf get should suggest similar key for case difference', async (t) => {
248
+ const { sandbox, mockLogger, mockConfig } = t.context
249
+
250
+ const { default: ConfCommand } = await esmock('./config.js', {}, {
251
+ '#lib/logger/index.js': {
252
+ default: () => mockLogger
253
+ }
254
+ })
255
+
256
+ const command = new ConfCommand()
257
+ command.config = mockConfig
258
+ command.logger = mockLogger
259
+ command.debug = sandbox.stub()
260
+
261
+ const error = await t.throwsAsync(
262
+ () => command.action('get', 'loglevel', undefined, {}),
263
+ { code: 'UNKNOWN_CONFIG_KEY' }
264
+ )
265
+ t.true(error.suggestion.includes('Did you mean: logLevel?'))
223
266
  })
224
267
 
225
268
  // =============================================================================
@@ -420,7 +463,7 @@ test('conf delete should show error when key is missing', async (t) => {
420
463
  t.false(mockConfig.delete.called)
421
464
  })
422
465
 
423
- test('conf delete should show error for unknown key', async (t) => {
466
+ test('conf delete should throw UnknownConfigKeyError for unknown key', async (t) => {
424
467
  const { sandbox, mockLogger, mockConfig } = t.context
425
468
 
426
469
  const { default: ConfCommand } = await esmock('./config.js', {}, {
@@ -434,9 +477,10 @@ test('conf delete should show error for unknown key', async (t) => {
434
477
  command.logger = mockLogger
435
478
  command.debug = sandbox.stub()
436
479
 
437
- await command.action('delete', 'unknownKey', undefined, {})
438
-
439
- t.true(mockLogger.error.calledOnce)
480
+ await t.throwsAsync(
481
+ () => command.action('delete', 'unknownKey', undefined, {}),
482
+ { code: 'UNKNOWN_CONFIG_KEY' }
483
+ )
440
484
  t.false(mockConfig.delete.called)
441
485
  })
442
486
 
@@ -485,7 +529,7 @@ test('conf reset should reset all config when no key provided', async (t) => {
485
529
  t.true(mockLogger.info.calledWith('Configuration reset to defaults'))
486
530
  })
487
531
 
488
- test('conf reset should show error for unknown key', async (t) => {
532
+ test('conf reset should throw UnknownConfigKeyError for unknown key', async (t) => {
489
533
  const { sandbox, mockLogger, mockConfig } = t.context
490
534
 
491
535
  const { default: ConfCommand } = await esmock('./config.js', {}, {
@@ -499,9 +543,10 @@ test('conf reset should show error for unknown key', async (t) => {
499
543
  command.logger = mockLogger
500
544
  command.debug = sandbox.stub()
501
545
 
502
- await command.action('reset', 'unknownKey', undefined, {})
503
-
504
- t.true(mockLogger.error.calledOnce)
546
+ await t.throwsAsync(
547
+ () => command.action('reset', 'unknownKey', undefined, {}),
548
+ { code: 'UNKNOWN_CONFIG_KEY' }
549
+ )
505
550
  t.false(mockConfig.reset.called)
506
551
  })
507
552
 
@@ -533,7 +578,7 @@ test('conf path should show config file path', async (t) => {
533
578
  // Invalid operation
534
579
  // =============================================================================
535
580
 
536
- test('conf should show error for unknown operation', async (t) => {
581
+ test('conf should throw UnknownConfigOperationError for unknown operation', async (t) => {
537
582
  const { sandbox, mockLogger, mockConfig } = t.context
538
583
 
539
584
  const { default: ConfCommand } = await esmock('./config.js', {}, {
@@ -547,12 +592,56 @@ test('conf should show error for unknown operation', async (t) => {
547
592
  command.logger = mockLogger
548
593
  command.debug = sandbox.stub()
549
594
 
550
- await command.action('unknownOp', undefined, undefined, {})
595
+ const error = await t.throwsAsync(
596
+ () => command.action('unknownOp', undefined, undefined, {}),
597
+ { code: 'UNKNOWN_CONFIG_OPERATION' }
598
+ )
599
+ t.true(error.message.includes('Unknown operation: unknownOp'))
600
+ t.true(error.suggestion.includes('Valid operations:'))
601
+ })
551
602
 
552
- t.true(mockLogger.error.calledOnce)
553
- t.true(mockLogger.error.calledWith('Unknown operation: unknownOp'))
554
- t.true(mockLogger.info.calledOnce)
555
- t.true(mockLogger.info.firstCall.args[0].includes('Valid operations:'))
603
+ test('conf should suggest similar operation for close misspelling', async (t) => {
604
+ const { sandbox, mockLogger, mockConfig } = t.context
605
+
606
+ const { default: ConfCommand } = await esmock('./config.js', {}, {
607
+ '#lib/logger/index.js': {
608
+ default: () => mockLogger
609
+ }
610
+ })
611
+
612
+ const command = new ConfCommand()
613
+ command.config = mockConfig
614
+ command.logger = mockLogger
615
+ command.debug = sandbox.stub()
616
+
617
+ const error = await t.throwsAsync(
618
+ () => command.action('gt', undefined, undefined, {}),
619
+ { code: 'UNKNOWN_CONFIG_OPERATION' }
620
+ )
621
+ t.true(error.message.includes('Unknown operation: gt'))
622
+ t.true(error.suggestion.includes('Did you mean: get?'))
623
+ })
624
+
625
+ test('conf should suggest similar operation for transposition', async (t) => {
626
+ const { sandbox, mockLogger, mockConfig } = t.context
627
+
628
+ const { default: ConfCommand } = await esmock('./config.js', {}, {
629
+ '#lib/logger/index.js': {
630
+ default: () => mockLogger
631
+ }
632
+ })
633
+
634
+ const command = new ConfCommand()
635
+ command.config = mockConfig
636
+ command.logger = mockLogger
637
+ command.debug = sandbox.stub()
638
+
639
+ const error = await t.throwsAsync(
640
+ () => command.action('ste', undefined, undefined, {}),
641
+ { code: 'UNKNOWN_CONFIG_OPERATION' }
642
+ )
643
+ t.true(error.message.includes('Unknown operation: ste'))
644
+ t.true(error.suggestion.includes('Did you mean: set?'))
556
645
  })
557
646
 
558
647
  // =============================================================================
@@ -1,11 +1,18 @@
1
1
  import InvalidInputError from '../input/invalid-input-error.js'
2
- import QuireError from '../quire-error.js'
2
+ import { docsUrl } from '#helpers/docs-url.js'
3
+ import { suggestSimilar, formatSuggestion } from '#helpers/suggest-similar.js'
3
4
 
4
5
  /**
5
6
  * Error thrown when a requested help topic does not exist
7
+ *
8
+ * @param {string} topic - The unrecognized topic name
9
+ * @param {string[]} validTopics - Array of available topic names
6
10
  */
7
11
  export default class HelpTopicNotFoundError extends InvalidInputError {
8
- constructor(topic) {
12
+ constructor(topic, validTopics = []) {
13
+ const suggestion = formatSuggestion(suggestSimilar(topic, validTopics))
14
+ || 'Run "quire help --list" to see available topics'
15
+
9
16
  super(
10
17
  `Unknown help topic: ${topic}`,
11
18
  {
@@ -13,8 +20,9 @@ export default class HelpTopicNotFoundError extends InvalidInputError {
13
20
  exitCode: 2,
14
21
  inputValue: topic,
15
22
  inputName: 'topic',
16
- suggestion: 'Run "quire help --list" to see available topics',
17
- docsUrl: `${QuireError.DOCS_BASE}/quire-commands/`
23
+ suggestion,
24
+ docsUrl: docsUrl('quire-commands/'),
25
+ showDebugHint: false
18
26
  }
19
27
  )
20
28
  this.topic = topic
@@ -45,7 +45,11 @@ export {
45
45
  } from './install/index.js'
46
46
 
47
47
  // Validation errors (exit code: 4)
48
- export { default as ValidationError } from './validation/validation-error.js'
48
+ export {
49
+ ValidationError,
50
+ UnknownConfigKeyError,
51
+ UnknownConfigOperationError
52
+ } from './validation/index.js'
49
53
 
50
54
  // Input errors (exit code: 2)
51
55
  export { InvalidInputError } from './input/index.js'
@@ -0,0 +1,3 @@
1
+ export { default as ValidationError } from './validation-error.js'
2
+ export { default as UnknownConfigKeyError } from './unknown-config-key-error.js'
3
+ export { default as UnknownConfigOperationError } from './unknown-config-operation-error.js'
@@ -0,0 +1,27 @@
1
+ import QuireError from '../quire-error.js'
2
+ import { docsUrl } from '#helpers/docs-url.js'
3
+ import { suggestSimilar, formatSuggestion } from '#helpers/suggest-similar.js'
4
+
5
+ /**
6
+ * Error thrown when an unrecognized configuration key is used
7
+ *
8
+ * @param {string} key - The unrecognized key
9
+ * @param {string[]} validKeys - Array of valid configuration keys
10
+ */
11
+ export default class UnknownConfigKeyError extends QuireError {
12
+ constructor(key, validKeys) {
13
+ const suggestion = formatSuggestion(suggestSimilar(key, validKeys))
14
+ || `Valid keys: ${validKeys.join(', ')}`
15
+
16
+ super(
17
+ `Unknown configuration key: ${key}`,
18
+ {
19
+ code: 'UNKNOWN_CONFIG_KEY',
20
+ exitCode: 4,
21
+ suggestion,
22
+ docsUrl: docsUrl('quire-commands/'),
23
+ showDebugHint: false
24
+ }
25
+ )
26
+ }
27
+ }
@@ -0,0 +1,24 @@
1
+ import QuireError from '../quire-error.js'
2
+ import { docsUrl } from '#helpers/docs-url.js'
3
+ import { suggestSimilar, formatSuggestion } from '#helpers/suggest-similar.js'
4
+
5
+ /**
6
+ * Error thrown when an unrecognized settings operation is used
7
+ */
8
+ export default class UnknownConfigOperationError extends QuireError {
9
+ constructor(operation, validOperations) {
10
+ const suggestion = formatSuggestion(suggestSimilar(operation, validOperations))
11
+ || `Valid operations: ${validOperations.join(', ')}`
12
+
13
+ super(
14
+ `Unknown operation: ${operation}`,
15
+ {
16
+ code: 'UNKNOWN_CONFIG_OPERATION',
17
+ exitCode: 4,
18
+ suggestion,
19
+ docsUrl: docsUrl('quire-commands/'),
20
+ showDebugHint: false
21
+ }
22
+ )
23
+ }
24
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Fuzzy string matching for CLI suggestions
3
+ *
4
+ * Uses Damerau-Levenshtein distance (optimal string alignment variant)
5
+ * to find close matches for misspelled user input. The algorithm and
6
+ * parameters match Commander.js's built-in suggestion implementation
7
+ * for consistency.
8
+ *
9
+ * @module helpers/suggest-similar
10
+ */
11
+
12
+ const MAX_DISTANCE = 3
13
+ const MIN_SIMILARITY = 0.4
14
+
15
+ /**
16
+ * Calculate Damerau-Levenshtein distance between two strings
17
+ *
18
+ * Supports insertions, deletions, substitutions, and transpositions
19
+ * of adjacent characters. Each substring is edited at most once
20
+ * (optimal string alignment variant).
21
+ *
22
+ * @param {string} a - First string
23
+ * @param {string} b - Second string
24
+ * @returns {number} Edit distance
25
+ * @private
26
+ */
27
+ function editDistance(a, b) {
28
+ if (Math.abs(a.length - b.length) > MAX_DISTANCE) {
29
+ return Math.max(a.length, b.length)
30
+ }
31
+
32
+ const d = []
33
+
34
+ for (let i = 0; i <= a.length; ++i) d[i] = [i]
35
+ for (let j = 0; j <= b.length; ++j) d[0][j] = j
36
+
37
+ for (let j = 1; j <= b.length; ++j) {
38
+ for (let i = 1; i <= a.length; ++i) {
39
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1
40
+ d[i][j] = Math.min(
41
+ d[i - 1][j] + 1, // deletion
42
+ d[i][j - 1] + 1, // insertion
43
+ d[i - 1][j - 1] + cost, // substitution
44
+ )
45
+ // transposition
46
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
47
+ d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1)
48
+ }
49
+ }
50
+ }
51
+
52
+ return d[a.length][b.length]
53
+ }
54
+
55
+ /**
56
+ * Find similar strings from a list of candidates
57
+ *
58
+ * Returns the closest matches by edit distance, filtered by a minimum
59
+ * similarity threshold (40%). Only returns matches at the best distance
60
+ * found (does not mix distances).
61
+ *
62
+ * @param {string} word - The misspelled input
63
+ * @param {string[]} candidates - Valid values to match against
64
+ * @returns {string[]} Sorted array of best matches (empty if none close enough)
65
+ *
66
+ * @example
67
+ * suggestSimilar('gt', ['get', 'set', 'delete', 'reset', 'path'])
68
+ * // → ['get']
69
+ *
70
+ * @example
71
+ * suggestSimilar('logLeve', ['logLevel', 'logPrefix', 'verbose'])
72
+ * // → ['logLevel']
73
+ */
74
+ export function suggestSimilar(word, candidates) {
75
+ if (!word || !candidates?.length) return []
76
+
77
+ const unique = [...new Set(candidates)]
78
+ let similar = []
79
+ let bestDistance = MAX_DISTANCE
80
+
81
+ for (const candidate of unique) {
82
+ if (candidate.length <= 1) continue
83
+
84
+ const distance = editDistance(word, candidate)
85
+ const length = Math.max(word.length, candidate.length)
86
+ const similarity = (length - distance) / length
87
+
88
+ if (similarity > MIN_SIMILARITY) {
89
+ if (distance < bestDistance) {
90
+ bestDistance = distance
91
+ similar = [candidate]
92
+ } else if (distance === bestDistance) {
93
+ similar.push(candidate)
94
+ }
95
+ }
96
+ }
97
+
98
+ return similar.sort((a, b) => a.localeCompare(b))
99
+ }
100
+
101
+ /**
102
+ * Format suggestion matches into a user-facing string
103
+ *
104
+ * @param {string[]} matches - Array of suggested matches
105
+ * @returns {string|undefined} Formatted suggestion or undefined if no matches
106
+ *
107
+ * @example
108
+ * formatSuggestion(['logLevel'])
109
+ * // → 'Did you mean: logLevel?'
110
+ *
111
+ * @example
112
+ * formatSuggestion(['get', 'set'])
113
+ * // → 'Did you mean one of: get, set?'
114
+ *
115
+ * @example
116
+ * formatSuggestion([])
117
+ * // → undefined
118
+ */
119
+ export function formatSuggestion(matches) {
120
+ if (!matches?.length) return undefined
121
+ return (matches.length === 1)
122
+ ? `Did you mean: ${matches[0]}?`
123
+ : `Did you mean one of: ${matches.join(', ')}?`
124
+ }
@@ -0,0 +1,108 @@
1
+ import test from 'ava'
2
+ import { suggestSimilar, formatSuggestion } from './suggest-similar.js'
3
+
4
+ // ─────────────────────────────────────────────────────────────────────────────
5
+ // suggestSimilar
6
+ // ─────────────────────────────────────────────────────────────────────────────
7
+
8
+ test('suggestSimilar returns match for single-character deletion', (t) => {
9
+ const result = suggestSimilar('gt', ['get', 'set', 'delete', 'reset', 'path'])
10
+ t.deepEqual(result, ['get'])
11
+ })
12
+
13
+ test('suggestSimilar returns match for single-character insertion', (t) => {
14
+ const result = suggestSimilar('geet', ['get', 'set', 'delete'])
15
+ t.deepEqual(result, ['get'])
16
+ })
17
+
18
+ test('suggestSimilar returns match for single-character substitution', (t) => {
19
+ const result = suggestSimilar('gat', ['get', 'set', 'delete'])
20
+ t.deepEqual(result, ['get'])
21
+ })
22
+
23
+ test('suggestSimilar returns match for transposition', (t) => {
24
+ const result = suggestSimilar('gte', ['get', 'set', 'delete'])
25
+ t.deepEqual(result, ['get'])
26
+ })
27
+
28
+ test('suggestSimilar returns match for camelCase near-miss', (t) => {
29
+ const result = suggestSimilar('logLeve', ['logLevel', 'logPrefix', 'verbose'])
30
+ t.deepEqual(result, ['logLevel'])
31
+ })
32
+
33
+ test('suggestSimilar returns match for case difference', (t) => {
34
+ // 'loglevel' vs 'logLevel' differs by one character (L vs l)
35
+ const result = suggestSimilar('loglevel', ['logLevel', 'logPrefix', 'verbose'])
36
+ t.deepEqual(result, ['logLevel'])
37
+ })
38
+
39
+ test('suggestSimilar returns empty array when no candidates are similar', (t) => {
40
+ const result = suggestSimilar('xyzabc', ['get', 'set', 'delete', 'reset', 'path'])
41
+ t.deepEqual(result, [])
42
+ })
43
+
44
+ test('suggestSimilar returns empty array for empty input', (t) => {
45
+ t.deepEqual(suggestSimilar('', ['get', 'set']), [])
46
+ })
47
+
48
+ test('suggestSimilar returns empty array for null input', (t) => {
49
+ t.deepEqual(suggestSimilar(null, ['get', 'set']), [])
50
+ })
51
+
52
+ test('suggestSimilar returns empty array for empty candidates', (t) => {
53
+ t.deepEqual(suggestSimilar('get', []), [])
54
+ })
55
+
56
+ test('suggestSimilar returns empty array for null candidates', (t) => {
57
+ t.deepEqual(suggestSimilar('get', null), [])
58
+ })
59
+
60
+ test('suggestSimilar excludes single-character candidates', (t) => {
61
+ // 'g' is distance 0 from 'g', but should be excluded
62
+ const result = suggestSimilar('g', ['g', 'get', 'set'])
63
+ t.deepEqual(result, [])
64
+ })
65
+
66
+ test('suggestSimilar deduplicates candidates', (t) => {
67
+ const result = suggestSimilar('gt', ['get', 'get', 'get'])
68
+ t.deepEqual(result, ['get'])
69
+ })
70
+
71
+ test('suggestSimilar returns multiple matches sorted alphabetically', (t) => {
72
+ // 'bild' is distance 1 from 'bind' and 'build' — both should match
73
+ // but 'build' is distance 1 (deletion), 'bind' is distance 2 (substitution + deletion)
74
+ // Let's use a case where two candidates have equal distance
75
+ const result = suggestSimilar('delet', ['delete', 'reset'])
76
+ // 'delet' → 'delete' distance 1, 'delet' → 'reset' distance 2
77
+ t.deepEqual(result, ['delete'])
78
+ })
79
+
80
+ test('suggestSimilar returns multiple equally-close matches', (t) => {
81
+ // Both 'bar' and 'baz' are distance 1 from 'bax'
82
+ const result = suggestSimilar('bax', ['bar', 'baz', 'foo'])
83
+ t.deepEqual(result, ['bar', 'baz'])
84
+ })
85
+
86
+ // ─────────────────────────────────────────────────────────────────────────────
87
+ // formatSuggestion
88
+ // ─────────────────────────────────────────────────────────────────────────────
89
+
90
+ test('formatSuggestion returns undefined for empty array', (t) => {
91
+ t.is(formatSuggestion([]), undefined)
92
+ })
93
+
94
+ test('formatSuggestion returns undefined for null', (t) => {
95
+ t.is(formatSuggestion(null), undefined)
96
+ })
97
+
98
+ test('formatSuggestion returns undefined for undefined', (t) => {
99
+ t.is(formatSuggestion(undefined), undefined)
100
+ })
101
+
102
+ test('formatSuggestion formats single match', (t) => {
103
+ t.is(formatSuggestion(['logLevel']), 'Did you mean: logLevel?')
104
+ })
105
+
106
+ test('formatSuggestion formats multiple matches', (t) => {
107
+ t.is(formatSuggestion(['bar', 'baz']), 'Did you mean one of: bar, baz?')
108
+ })
@@ -15,6 +15,7 @@ export {
15
15
  quietOption,
16
16
  verboseOption,
17
17
  debugOption,
18
+ reducedMotionOption,
18
19
  outputModeOptions,
19
20
  outputModeHelpText,
20
21
  withOutputModes,
@@ -124,6 +124,24 @@ export const debugOption = [
124
124
  { conflicts: ['quiet'] }
125
125
  ]
126
126
 
127
+ /**
128
+ * Reduced motion option - disable spinner animation and line overwriting
129
+ *
130
+ * When enabled, the reporter outputs static text on new lines instead of
131
+ * animated spinners that overwrite the current line. This is useful for:
132
+ * - Screen reader users (animated overwriting disrupts reading flow)
133
+ * - Users who prefer reduced motion
134
+ * - Environments where terminal animation is problematic
135
+ *
136
+ * Respects config default: `quire settings set reducedMotion true`
137
+ * Also respects REDUCED_MOTION environment variable.
138
+ *
139
+ * @type {Array}
140
+ */
141
+ export const reducedMotionOption = [
142
+ '--reduced-motion', 'disable spinner animation and line overwriting',
143
+ ]
144
+
127
145
  /**
128
146
  * Color options - enable/disable colored output
129
147
  *