diff --git a/src/core/config-schema.ts b/src/core/config-schema.ts index 0614ed33ec..b1d694a301 100644 --- a/src/core/config-schema.ts +++ b/src/core/config-schema.ts @@ -146,13 +146,17 @@ export function deleteNestedValue(obj: Record, path: string): b * Coerce a string value to its appropriate type. * - "true" / "false" -> boolean * - Numeric strings -> number + * - JSON arrays/objects -> parsed containers * - Everything else -> string * * @param value - The string value to coerce * @param forceString - If true, always return the value as a string * @returns The coerced value */ -export function coerceValue(value: string, forceString: boolean = false): string | number | boolean { +export function coerceValue( + value: string, + forceString: boolean = false +): string | number | boolean | unknown[] | Record { if (forceString) { return value; } @@ -171,9 +175,39 @@ export function coerceValue(value: string, forceString: boolean = false): string return num; } + const jsonContainer = parseJsonContainer(value); + if (jsonContainer !== undefined) { + return jsonContainer; + } + return value; } +function parseJsonContainer(value: string): unknown[] | Record | undefined { + const trimmed = value.trim(); + const looksLikeContainer = + (trimmed.startsWith('[') && trimmed.endsWith(']')) || + (trimmed.startsWith('{') && trimmed.endsWith('}')); + + if (!looksLikeContainer) { + return undefined; + } + + try { + const parsed: unknown = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + return parsed; + } + if (parsed !== null && typeof parsed === 'object') { + return parsed as Record; + } + } catch { + return undefined; + } + + return undefined; +} + /** * Format a value for YAML-like display. * diff --git a/test/commands/config.test.ts b/test/commands/config.test.ts index 6e65068b87..d6ac830d3d 100644 --- a/test/commands/config.test.ts +++ b/test/commands/config.test.ts @@ -1,13 +1,22 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { Command } from 'commander'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; +async function runConfigCommand(args: string[]): Promise { + const { registerConfigCommand } = await import('../../src/commands/config.js'); + const program = new Command(); + registerConfigCommand(program); + await program.parseAsync(['node', 'openspec', 'config', ...args]); +} + describe('config command integration', () => { // These tests use real file system operations with XDG_CONFIG_HOME override let tempDir: string; let originalEnv: NodeJS.ProcessEnv; let consoleErrorSpy: ReturnType; + let consoleLogSpy: ReturnType; beforeEach(() => { // Create unique temp directory for each test @@ -20,6 +29,7 @@ describe('config command integration', () => { // Spy on console.error consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); }); afterEach(() => { @@ -31,6 +41,7 @@ describe('config command integration', () => { // Restore spies consoleErrorSpy.mockRestore(); + consoleLogSpy.mockRestore(); // Reset module cache to pick up new XDG_CONFIG_HOME vi.resetModules(); @@ -89,6 +100,22 @@ describe('config command integration', () => { expect(config.featureFlags).toEqual({}); expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid JSON')); }); + + it('should set workflows from JSON array syntax', async () => { + await runConfigCommand([ + 'set', + 'workflows', + '["new","ff","apply","archive"]', + ]); + + const { getGlobalConfig } = await import('../../src/core/global-config.js'); + const config = getGlobalConfig(); + + expect(config.workflows).toEqual(['new', 'ff', 'apply', 'archive']); + expect(consoleLogSpy).toHaveBeenCalledWith( + 'Set workflows = new,ff,apply,archive' + ); + }); }); describe('config command shell completion registry', () => { diff --git a/test/core/config-schema.test.ts b/test/core/config-schema.test.ts index eeff81ccc8..4a76ea1f46 100644 --- a/test/core/config-schema.test.ts +++ b/test/core/config-schema.test.ts @@ -151,6 +151,23 @@ describe('config-schema', () => { expect(coerceValue('hello')).toBe('hello'); }); + it('should parse JSON arrays', () => { + expect(coerceValue('["new","ff","apply","archive"]')).toEqual([ + 'new', + 'ff', + 'apply', + 'archive', + ]); + }); + + it('should parse JSON objects', () => { + expect(coerceValue('{"nested":"value"}')).toEqual({ nested: 'value' }); + }); + + it('should keep malformed JSON containers as strings', () => { + expect(coerceValue('["new",')).toBe('["new",'); + }); + it('should keep strings that start with numbers but are not numbers', () => { expect(coerceValue('123abc')).toBe('123abc'); }); @@ -167,6 +184,7 @@ describe('config-schema', () => { expect(coerceValue('true', true)).toBe('true'); expect(coerceValue('42', true)).toBe('42'); expect(coerceValue('hello', true)).toBe('hello'); + expect(coerceValue('["new"]', true)).toBe('["new"]'); }); it('should not coerce Infinity to number (not finite)', () => { @@ -318,6 +336,16 @@ describe('config-schema', () => { expect(result.success).toBe(true); expect((config.featureFlags as Record).experimental).toBe(false); }); + + it('should accept setting workflows from JSON array syntax', () => { + const config: Record = { featureFlags: {}, profile: 'custom' }; + const value = coerceValue('["new","ff","apply","archive"]'); + setNestedValue(config, 'workflows', value); + + const result = validateConfig(config); + expect(result.success).toBe(true); + expect(config.workflows).toEqual(['new', 'ff', 'apply', 'archive']); + }); }); describe('GlobalConfigSchema', () => {