@md2do/cli 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +25 -0
  3. package/coverage/coverage-final.json +3 -2
  4. package/coverage/index.html +11 -11
  5. package/coverage/lcov-report/index.html +11 -11
  6. package/coverage/lcov-report/src/cli.ts.html +10 -4
  7. package/coverage/lcov-report/src/commands/config.ts.html +2269 -0
  8. package/coverage/lcov-report/src/commands/index.html +22 -7
  9. package/coverage/lcov-report/src/commands/index.ts.html +7 -4
  10. package/coverage/lcov-report/src/commands/list.ts.html +1 -1
  11. package/coverage/lcov-report/src/commands/stats.ts.html +1 -1
  12. package/coverage/lcov-report/src/commands/todoist.ts.html +1 -1
  13. package/coverage/lcov-report/src/formatters/index.html +1 -1
  14. package/coverage/lcov-report/src/formatters/json.ts.html +1 -1
  15. package/coverage/lcov-report/src/formatters/pretty.ts.html +1 -1
  16. package/coverage/lcov-report/src/index.html +5 -5
  17. package/coverage/lcov-report/src/index.ts.html +1 -1
  18. package/coverage/lcov-report/src/scanner.ts.html +1 -1
  19. package/coverage/lcov.info +746 -3
  20. package/coverage/src/cli.ts.html +10 -4
  21. package/coverage/src/commands/config.ts.html +2269 -0
  22. package/coverage/src/commands/index.html +22 -7
  23. package/coverage/src/commands/index.ts.html +7 -4
  24. package/coverage/src/commands/list.ts.html +1 -1
  25. package/coverage/src/commands/stats.ts.html +1 -1
  26. package/coverage/src/commands/todoist.ts.html +1 -1
  27. package/coverage/src/formatters/index.html +1 -1
  28. package/coverage/src/formatters/json.ts.html +1 -1
  29. package/coverage/src/formatters/pretty.ts.html +1 -1
  30. package/coverage/src/index.html +5 -5
  31. package/coverage/src/index.ts.html +1 -1
  32. package/coverage/src/scanner.ts.html +1 -1
  33. package/dist/cli.js +466 -6
  34. package/dist/index.d.ts +6 -1
  35. package/dist/index.js +461 -0
  36. package/package.json +5 -2
  37. package/src/cli.ts +2 -0
  38. package/src/commands/config.ts +731 -0
  39. package/src/commands/index.ts +1 -0
@@ -0,0 +1,731 @@
1
+ import { Command } from 'commander';
2
+ import { loadConfig, validateConfig, type Config } from '@md2do/config';
3
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { homedir } from 'os';
6
+ import { execSync } from 'child_process';
7
+ import yaml from 'js-yaml';
8
+
9
+ interface ConfigInitOptions {
10
+ global?: boolean;
11
+ format?: 'json' | 'yaml' | 'js';
12
+ defaultAssignee?: string;
13
+ workdayStart?: string;
14
+ workdayEnd?: string;
15
+ defaultDueTime?: 'start' | 'end';
16
+ outputFormat?: 'pretty' | 'table' | 'json';
17
+ colors?: boolean;
18
+ warnings?: 'recommended' | 'strict' | 'off';
19
+ }
20
+
21
+ interface ConfigSetOptions {
22
+ global?: boolean;
23
+ }
24
+
25
+ interface ConfigGetOptions {
26
+ global?: boolean;
27
+ }
28
+
29
+ interface ConfigListOptions {
30
+ showOrigin?: boolean;
31
+ }
32
+
33
+ interface ConfigEditOptions {
34
+ global?: boolean;
35
+ }
36
+
37
+ /**
38
+ * Create the config command group
39
+ */
40
+ export function createConfigCommand(): Command {
41
+ const command = new Command('config');
42
+
43
+ command.description('Manage md2do configuration');
44
+
45
+ // Add subcommands
46
+ command.addCommand(createConfigInitCommand());
47
+ command.addCommand(createConfigSetCommand());
48
+ command.addCommand(createConfigGetCommand());
49
+ command.addCommand(createConfigListCommand());
50
+ command.addCommand(createConfigEditCommand());
51
+ command.addCommand(createConfigValidateCommand());
52
+
53
+ return command;
54
+ }
55
+
56
+ /**
57
+ * Create the 'config init' subcommand
58
+ */
59
+ function createConfigInitCommand(): Command {
60
+ const command = new Command('init');
61
+
62
+ command
63
+ .description('Initialize md2do configuration with interactive prompts')
64
+ .option('-g, --global', 'Create global configuration in home directory')
65
+ .option(
66
+ '--format <type>',
67
+ 'Config file format (json|yaml|js)',
68
+ 'json' as 'json' | 'yaml' | 'js',
69
+ )
70
+ .option('--default-assignee <username>', 'Default assignee username')
71
+ .option('--workday-start <time>', 'Work day start time (HH:MM)')
72
+ .option('--workday-end <time>', 'Work day end time (HH:MM)')
73
+ .option(
74
+ '--default-due-time <when>',
75
+ 'Default due time (start|end)',
76
+ 'end' as 'start' | 'end',
77
+ )
78
+ .option(
79
+ '--output-format <type>',
80
+ 'Output format (pretty|table|json)',
81
+ 'pretty' as 'pretty' | 'table' | 'json',
82
+ )
83
+ .option('--no-colors', 'Disable colored output')
84
+ .option(
85
+ '--warnings <level>',
86
+ 'Warning level (recommended|strict|off)',
87
+ 'recommended' as 'recommended' | 'strict' | 'off',
88
+ )
89
+ .action(async (options: ConfigInitOptions) => {
90
+ try {
91
+ await configInitAction(options);
92
+ } catch (error) {
93
+ if (error instanceof Error && error.message === 'canceled') {
94
+ const p = await import('@clack/prompts');
95
+ p.cancel('Configuration canceled');
96
+ process.exit(0);
97
+ }
98
+ console.error(
99
+ 'Error:',
100
+ error instanceof Error ? error.message : String(error),
101
+ );
102
+ process.exit(1);
103
+ }
104
+ });
105
+
106
+ return command;
107
+ }
108
+
109
+ /**
110
+ * Action handler for 'config init' command
111
+ */
112
+ async function configInitAction(options: ConfigInitOptions): Promise<void> {
113
+ // Dynamic import for ESM-only @clack/prompts
114
+ const p = await import('@clack/prompts');
115
+
116
+ p.intro('Welcome to md2do configuration!');
117
+
118
+ // Determine if we're in interactive mode
119
+ const hasAnyOption =
120
+ options.defaultAssignee !== undefined ||
121
+ options.workdayStart !== undefined ||
122
+ options.workdayEnd !== undefined ||
123
+ options.defaultDueTime !== undefined ||
124
+ options.outputFormat !== undefined ||
125
+ options.colors !== undefined ||
126
+ options.warnings !== undefined;
127
+
128
+ const interactive = !hasAnyOption;
129
+
130
+ const config: Partial<Config> = {};
131
+
132
+ if (interactive) {
133
+ // Interactive prompts
134
+ const defaultAssignee = await p.text({
135
+ message: 'Your username (for filtering tasks):',
136
+ placeholder: 'Leave empty to skip',
137
+ validate: (value) => {
138
+ if (value && !/^[a-zA-Z0-9_-]+$/.test(value)) {
139
+ return 'Username should only contain letters, numbers, dashes, and underscores';
140
+ }
141
+ },
142
+ });
143
+
144
+ const workdayStart = await p.text({
145
+ message: 'Work day start time (HH:MM):',
146
+ initialValue: '08:00',
147
+ validate: (value) => {
148
+ if (typeof value === 'string' && !/^\d{2}:\d{2}$/.test(value)) {
149
+ return 'Time must be in HH:MM format';
150
+ }
151
+ },
152
+ });
153
+
154
+ const workdayEnd = await p.text({
155
+ message: 'Work day end time (HH:MM):',
156
+ initialValue: '17:00',
157
+ validate: (value) => {
158
+ if (typeof value === 'string' && !/^\d{2}:\d{2}$/.test(value)) {
159
+ return 'Time must be in HH:MM format';
160
+ }
161
+ },
162
+ });
163
+
164
+ const defaultDueTime = await p.select({
165
+ message: 'Default due time:',
166
+ options: [
167
+ { value: 'end', label: 'End of day' },
168
+ { value: 'start', label: 'Start of day' },
169
+ ],
170
+ initialValue: 'end',
171
+ });
172
+
173
+ const outputFormat = await p.select({
174
+ message: 'Output format:',
175
+ options: [
176
+ { value: 'pretty', label: 'Pretty (human-readable)' },
177
+ { value: 'table', label: 'Table (structured)' },
178
+ { value: 'json', label: 'JSON (machine-readable)' },
179
+ ],
180
+ initialValue: 'pretty',
181
+ });
182
+
183
+ const colors = await p.confirm({
184
+ message: 'Enable colored output?',
185
+ initialValue: true,
186
+ });
187
+
188
+ const warnings = await p.select({
189
+ message: 'Warning level:',
190
+ options: [
191
+ {
192
+ value: 'recommended',
193
+ label: 'Recommended (validates format, metadata optional)',
194
+ },
195
+ {
196
+ value: 'strict',
197
+ label: 'Strict (enforces complete metadata)',
198
+ },
199
+ { value: 'off', label: 'Off (no warnings)' },
200
+ ],
201
+ initialValue: 'recommended',
202
+ });
203
+
204
+ // Build config object
205
+ if (defaultAssignee && typeof defaultAssignee === 'string') {
206
+ config.defaultAssignee = defaultAssignee;
207
+ }
208
+
209
+ config.workday = {
210
+ startTime: typeof workdayStart === 'string' ? workdayStart : '08:00',
211
+ endTime: typeof workdayEnd === 'string' ? workdayEnd : '17:00',
212
+ defaultDueTime:
213
+ typeof defaultDueTime === 'string'
214
+ ? (defaultDueTime as 'start' | 'end')
215
+ : 'end',
216
+ };
217
+
218
+ config.output = {
219
+ format:
220
+ typeof outputFormat === 'string'
221
+ ? (outputFormat as 'pretty' | 'table' | 'json')
222
+ : 'pretty',
223
+ colors: typeof colors === 'boolean' ? colors : true,
224
+ paths: true,
225
+ };
226
+
227
+ if (typeof warnings === 'string' && warnings === 'off') {
228
+ config.warnings = { enabled: false };
229
+ } else if (typeof warnings === 'string' && warnings === 'strict') {
230
+ config.warnings = {
231
+ enabled: true,
232
+ rules: {
233
+ 'unsupported-bullet': 'warn',
234
+ 'malformed-checkbox': 'warn',
235
+ 'missing-space-after': 'warn',
236
+ 'missing-space-before': 'warn',
237
+ 'relative-date-no-context': 'warn',
238
+ 'missing-due-date': 'warn',
239
+ 'missing-completed-date': 'warn',
240
+ 'duplicate-todoist-id': 'error',
241
+ 'file-read-error': 'error',
242
+ },
243
+ };
244
+ }
245
+ // recommended is the default, no need to set explicitly
246
+ } else {
247
+ // Non-interactive mode: use CLI options
248
+ if (options.defaultAssignee) {
249
+ config.defaultAssignee = options.defaultAssignee;
250
+ }
251
+
252
+ config.workday = {
253
+ startTime: options.workdayStart || '08:00',
254
+ endTime: options.workdayEnd || '17:00',
255
+ defaultDueTime: options.defaultDueTime || 'end',
256
+ };
257
+
258
+ config.output = {
259
+ format: options.outputFormat || 'pretty',
260
+ colors: options.colors ?? true,
261
+ paths: true,
262
+ };
263
+
264
+ if (options.warnings === 'off') {
265
+ config.warnings = { enabled: false };
266
+ } else if (options.warnings === 'strict') {
267
+ config.warnings = {
268
+ enabled: true,
269
+ rules: {
270
+ 'unsupported-bullet': 'warn',
271
+ 'malformed-checkbox': 'warn',
272
+ 'missing-space-after': 'warn',
273
+ 'missing-space-before': 'warn',
274
+ 'relative-date-no-context': 'warn',
275
+ 'missing-due-date': 'warn',
276
+ 'missing-completed-date': 'warn',
277
+ 'duplicate-todoist-id': 'error',
278
+ 'file-read-error': 'error',
279
+ },
280
+ };
281
+ }
282
+ }
283
+
284
+ // Validate config
285
+ try {
286
+ validateConfig(config);
287
+ } catch (error) {
288
+ p.cancel(
289
+ `Invalid configuration: ${error instanceof Error ? error.message : String(error)}`,
290
+ );
291
+ process.exit(1);
292
+ }
293
+
294
+ // Determine config file path
295
+ const format = options.format || 'json';
296
+ const configPath = getConfigPath(options.global || false, format);
297
+
298
+ // Check if config already exists
299
+ if (existsSync(configPath)) {
300
+ if (interactive) {
301
+ const overwrite = await p.confirm({
302
+ message: `Configuration file already exists at ${configPath}. Overwrite?`,
303
+ initialValue: false,
304
+ });
305
+
306
+ if (!overwrite) {
307
+ p.cancel('Configuration canceled');
308
+ process.exit(0);
309
+ }
310
+ }
311
+ }
312
+
313
+ // Write config file
314
+ writeConfigFile(configPath, config, format);
315
+
316
+ p.outro(`✓ Configuration saved to ${configPath}`);
317
+ }
318
+
319
+ /**
320
+ * Create the 'config set' subcommand
321
+ */
322
+ function createConfigSetCommand(): Command {
323
+ const command = new Command('set');
324
+
325
+ command
326
+ .description('Set a configuration value')
327
+ .argument('<key>', 'Configuration key (e.g., workday.startTime)')
328
+ .argument('<value>', 'Configuration value')
329
+ .option('-g, --global', 'Set in global configuration')
330
+ .action((key: string, value: string, options: ConfigSetOptions) => {
331
+ try {
332
+ configSetAction(key, value, options);
333
+ } catch (error) {
334
+ console.error(
335
+ 'Error:',
336
+ error instanceof Error ? error.message : String(error),
337
+ );
338
+ process.exit(1);
339
+ }
340
+ });
341
+
342
+ return command;
343
+ }
344
+
345
+ /**
346
+ * Action handler for 'config set' command
347
+ */
348
+ function configSetAction(
349
+ key: string,
350
+ value: string,
351
+ options: ConfigSetOptions,
352
+ ): void {
353
+ const isGlobal = options.global || false;
354
+ const configPath = findExistingConfigPath(isGlobal);
355
+
356
+ let config: Partial<Config> = {};
357
+ let format: 'json' | 'yaml' | 'js' = 'json';
358
+
359
+ // Load existing config if exists
360
+ if (configPath) {
361
+ format = getConfigFormat(configPath);
362
+ const content = readFileSync(configPath, 'utf-8');
363
+ config = parseConfigFile(content, format);
364
+ } else {
365
+ // Create new config with default format
366
+ format = 'json';
367
+ }
368
+
369
+ // Parse and set value
370
+ const parsedValue = parseValue(value);
371
+ setNestedValue(config, key, parsedValue);
372
+
373
+ // Validate
374
+ try {
375
+ validateConfig(config);
376
+ } catch (error) {
377
+ console.error(
378
+ `Invalid configuration: ${error instanceof Error ? error.message : String(error)}`,
379
+ );
380
+ process.exit(1);
381
+ }
382
+
383
+ // Write back
384
+ const finalPath = configPath || getConfigPath(isGlobal, format);
385
+ writeConfigFile(finalPath, config, format);
386
+
387
+ console.log(`✓ Set ${key} = ${value} in ${finalPath}`);
388
+ }
389
+
390
+ /**
391
+ * Create the 'config get' subcommand
392
+ */
393
+ function createConfigGetCommand(): Command {
394
+ const command = new Command('get');
395
+
396
+ command
397
+ .description('Get a configuration value')
398
+ .argument('<key>', 'Configuration key (e.g., workday.startTime)')
399
+ .option('-g, --global', 'Get from global configuration only')
400
+ .action(async (key: string, options: ConfigGetOptions) => {
401
+ try {
402
+ await configGetAction(key, options);
403
+ } catch (error) {
404
+ console.error(
405
+ 'Error:',
406
+ error instanceof Error ? error.message : String(error),
407
+ );
408
+ process.exit(1);
409
+ }
410
+ });
411
+
412
+ return command;
413
+ }
414
+
415
+ /**
416
+ * Action handler for 'config get' command
417
+ */
418
+ async function configGetAction(
419
+ key: string,
420
+ options: ConfigGetOptions,
421
+ ): Promise<void> {
422
+ const config = options.global
423
+ ? await loadConfig({ loadGlobal: true, loadEnv: false, cwd: homedir() })
424
+ : await loadConfig();
425
+
426
+ const value = getNestedValue(config, key);
427
+
428
+ if (value === undefined) {
429
+ console.log('(not set)');
430
+ } else if (typeof value === 'object') {
431
+ console.log(JSON.stringify(value, null, 2));
432
+ } else {
433
+ console.log(value);
434
+ }
435
+ }
436
+
437
+ /**
438
+ * Create the 'config list' subcommand
439
+ */
440
+ function createConfigListCommand(): Command {
441
+ const command = new Command('list');
442
+
443
+ command
444
+ .description('Show all configuration values')
445
+ .option('--show-origin', 'Show where each value comes from')
446
+ .action(async (options: ConfigListOptions) => {
447
+ try {
448
+ await configListAction(options);
449
+ } catch (error) {
450
+ console.error(
451
+ 'Error:',
452
+ error instanceof Error ? error.message : String(error),
453
+ );
454
+ process.exit(1);
455
+ }
456
+ });
457
+
458
+ return command;
459
+ }
460
+
461
+ /**
462
+ * Action handler for 'config list' command
463
+ */
464
+ async function configListAction(options: ConfigListOptions): Promise<void> {
465
+ const config = await loadConfig();
466
+
467
+ console.log('');
468
+ console.log('Current configuration:');
469
+ console.log('');
470
+ console.log(JSON.stringify(config, null, 2));
471
+ console.log('');
472
+
473
+ if (options.showOrigin) {
474
+ console.log('Configuration sources:');
475
+ console.log(' 1. Default values (built-in)');
476
+ console.log(` 2. Global config (${getConfigPath(true, 'json')})`);
477
+ console.log(` 3. Project config (${getConfigPath(false, 'json')})`);
478
+ console.log(' 4. Environment variables');
479
+ console.log('');
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Create the 'config edit' subcommand
485
+ */
486
+ function createConfigEditCommand(): Command {
487
+ const command = new Command('edit');
488
+
489
+ command
490
+ .description('Open configuration file in your default editor')
491
+ .option('-g, --global', 'Edit global configuration')
492
+ .action((options: ConfigEditOptions) => {
493
+ try {
494
+ configEditAction(options);
495
+ } catch (error) {
496
+ console.error(
497
+ 'Error:',
498
+ error instanceof Error ? error.message : String(error),
499
+ );
500
+ process.exit(1);
501
+ }
502
+ });
503
+
504
+ return command;
505
+ }
506
+
507
+ /**
508
+ * Action handler for 'config edit' command
509
+ */
510
+ function configEditAction(options: ConfigEditOptions): void {
511
+ const isGlobal = options.global || false;
512
+ let configPath = findExistingConfigPath(isGlobal);
513
+
514
+ // Create config if it doesn't exist
515
+ if (!configPath) {
516
+ configPath = getConfigPath(isGlobal, 'json');
517
+ writeConfigFile(configPath, {}, 'json');
518
+ console.log(`Created new configuration file: ${configPath}`);
519
+ }
520
+
521
+ // Get editor
522
+ const editor =
523
+ process.env.VISUAL ||
524
+ process.env.EDITOR ||
525
+ (process.platform === 'win32' ? 'notepad' : 'vi');
526
+
527
+ console.log(`Opening ${configPath} in ${editor}...`);
528
+
529
+ try {
530
+ execSync(`${editor} '${configPath}'`, { stdio: 'inherit' });
531
+ } catch (error) {
532
+ console.error('Failed to open editor');
533
+ console.error(`You can manually edit the file at: ${configPath}`);
534
+ process.exit(1);
535
+ }
536
+ }
537
+
538
+ /**
539
+ * Create the 'config validate' subcommand
540
+ */
541
+ function createConfigValidateCommand(): Command {
542
+ const command = new Command('validate');
543
+
544
+ command.description('Validate current configuration').action(async () => {
545
+ try {
546
+ await configValidateAction();
547
+ } catch (error) {
548
+ console.error(
549
+ 'Error:',
550
+ error instanceof Error ? error.message : String(error),
551
+ );
552
+ process.exit(1);
553
+ }
554
+ });
555
+
556
+ return command;
557
+ }
558
+
559
+ /**
560
+ * Action handler for 'config validate' command
561
+ */
562
+ async function configValidateAction(): Promise<void> {
563
+ try {
564
+ const config = await loadConfig();
565
+ validateConfig(config);
566
+ console.log('✓ Configuration is valid');
567
+ } catch (error) {
568
+ console.error(
569
+ `Invalid configuration: ${error instanceof Error ? error.message : String(error)}`,
570
+ );
571
+ process.exit(1);
572
+ }
573
+ }
574
+
575
+ /**
576
+ * Helper: Get config file path
577
+ */
578
+ function getConfigPath(
579
+ isGlobal: boolean,
580
+ format: 'json' | 'yaml' | 'js',
581
+ ): string {
582
+ const base = isGlobal ? homedir() : process.cwd();
583
+ const fileName =
584
+ format === 'json'
585
+ ? '.md2do.json'
586
+ : format === 'yaml'
587
+ ? '.md2do.yaml'
588
+ : '.md2do.js';
589
+ return join(base, fileName);
590
+ }
591
+
592
+ /**
593
+ * Helper: Find existing config file path
594
+ */
595
+ function findExistingConfigPath(isGlobal: boolean): string | null {
596
+ const base = isGlobal ? homedir() : process.cwd();
597
+ const possibleFiles = [
598
+ '.md2do.json',
599
+ '.md2do.yaml',
600
+ '.md2do.yml',
601
+ '.md2do.js',
602
+ '.md2do.cjs',
603
+ 'md2do.config.js',
604
+ 'md2do.config.cjs',
605
+ ];
606
+
607
+ for (const file of possibleFiles) {
608
+ const path = join(base, file);
609
+ if (existsSync(path)) {
610
+ return path;
611
+ }
612
+ }
613
+
614
+ return null;
615
+ }
616
+
617
+ /**
618
+ * Helper: Get format from config path
619
+ */
620
+ function getConfigFormat(path: string): 'json' | 'yaml' | 'js' {
621
+ if (path.endsWith('.json')) return 'json';
622
+ if (path.endsWith('.yaml') || path.endsWith('.yml')) return 'yaml';
623
+ return 'js';
624
+ }
625
+
626
+ /**
627
+ * Helper: Parse config file content
628
+ */
629
+ function parseConfigFile(
630
+ content: string,
631
+ format: 'json' | 'yaml' | 'js',
632
+ ): Partial<Config> {
633
+ if (format === 'json') {
634
+ return JSON.parse(content) as Partial<Config>;
635
+ } else if (format === 'yaml') {
636
+ const loaded = yaml.load(content);
637
+ return (loaded || {}) as Partial<Config>;
638
+ } else {
639
+ // For JS files, we'd need to eval or require, which is complex
640
+ // For now, just parse as JSON
641
+ return JSON.parse(content) as Partial<Config>;
642
+ }
643
+ }
644
+
645
+ /**
646
+ * Helper: Write config file
647
+ */
648
+ function writeConfigFile(
649
+ path: string,
650
+ config: Partial<Config>,
651
+ format: 'json' | 'yaml' | 'js',
652
+ ): void {
653
+ let content: string;
654
+
655
+ if (format === 'json') {
656
+ content = JSON.stringify(config, null, 2) + '\n';
657
+ } else if (format === 'yaml') {
658
+ content = yaml.dump(config);
659
+ } else {
660
+ content = `module.exports = ${JSON.stringify(config, null, 2)};\n`;
661
+ }
662
+
663
+ writeFileSync(path, content, 'utf-8');
664
+ }
665
+
666
+ /**
667
+ * Helper: Parse value from string
668
+ */
669
+ function parseValue(value: string): unknown {
670
+ // Try boolean
671
+ if (value === 'true') return true;
672
+ if (value === 'false') return false;
673
+
674
+ // Try number
675
+ const num = Number(value);
676
+ if (!isNaN(num)) return num;
677
+
678
+ // Try JSON
679
+ try {
680
+ return JSON.parse(value);
681
+ } catch {
682
+ // Return as string
683
+ return value;
684
+ }
685
+ }
686
+
687
+ /**
688
+ * Helper: Get nested value from object
689
+ */
690
+ function getNestedValue(obj: unknown, path: string): unknown {
691
+ const keys = path.split('.');
692
+ let current: unknown = obj;
693
+
694
+ for (const key of keys) {
695
+ if (
696
+ current === null ||
697
+ current === undefined ||
698
+ typeof current !== 'object'
699
+ ) {
700
+ return undefined;
701
+ }
702
+ current = (current as Record<string, unknown>)[key];
703
+ }
704
+
705
+ return current;
706
+ }
707
+
708
+ /**
709
+ * Helper: Set nested value in object
710
+ */
711
+ function setNestedValue(
712
+ obj: Record<string, unknown>,
713
+ path: string,
714
+ value: unknown,
715
+ ): void {
716
+ const keys = path.split('.');
717
+ const lastKey = keys.pop();
718
+
719
+ if (!lastKey) return;
720
+
721
+ let current: Record<string, unknown> = obj;
722
+
723
+ for (const key of keys) {
724
+ if (!(key in current) || typeof current[key] !== 'object') {
725
+ current[key] = {};
726
+ }
727
+ current = current[key] as Record<string, unknown>;
728
+ }
729
+
730
+ current[lastKey] = value;
731
+ }