diff --git a/src/cli/cliRun.ts b/src/cli/cliRun.ts index 384af84d6..79b17309b 100644 --- a/src/cli/cliRun.ts +++ b/src/cli/cliRun.ts @@ -44,6 +44,8 @@ const semanticSuggestionMap: Record = { print: ['--stdout'], console: ['--stdout'], terminal: ['--stdout'], + plain: ['--no-color'], + monochrome: ['--no-color'], pipe: ['--stdin'], }; @@ -74,6 +76,7 @@ export const run = async () => { ) .option('--stdin', 'Read file paths from stdin, one per line (specified files are processed directly)') .option('--copy', 'Copy the generated output to system clipboard after processing') + .option('--no-color', 'Disable colored output (also respects NO_COLOR env variable, see https://no-color.org)') .option( '--token-count-tree [threshold]', 'Show file tree with token counts; optional threshold to show only files with ≥N tokens (e.g., --token-count-tree 100)', diff --git a/src/cli/types.ts b/src/cli/types.ts index 4faf03f3d..2702084e0 100644 --- a/src/cli/types.ts +++ b/src/cli/types.ts @@ -69,4 +69,10 @@ export interface CliOptions extends OptionValues { topFilesLen?: number; verbose?: boolean; quiet?: boolean; + + // Color Options + // The --no-color flag is handled by picocolors which checks process.argv. + // This property exists for Commander type compatibility and for propagating + // color settings to worker processes. + color?: boolean; } diff --git a/src/shared/logger.ts b/src/shared/logger.ts index 6be976a6a..73a00f86f 100644 --- a/src/shared/logger.ts +++ b/src/shared/logger.ts @@ -80,8 +80,9 @@ class RepomixLogger { } private formatArgs(args: unknown[]): string { + const useColors = pc.isColorSupported; return args - .map((arg) => (typeof arg === 'object' ? util.inspect(arg, { depth: null, colors: true }) : arg)) + .map((arg) => (typeof arg === 'object' ? util.inspect(arg, { depth: null, colors: useColors }) : arg)) .join(' '); } } diff --git a/src/shared/processConcurrency.ts b/src/shared/processConcurrency.ts index 0507131a2..59951d7a0 100644 --- a/src/shared/processConcurrency.ts +++ b/src/shared/processConcurrency.ts @@ -73,6 +73,12 @@ export const createWorkerPool = (options: WorkerOptions): Tinypool => { ); const startTime = process.hrtime.bigint(); + const noColorEnabled = Boolean(process.env.NO_COLOR) || process.argv.includes('--no-color'); + const childProcessEnv = { ...process.env }; + if (noColorEnabled) { + // Ensure child workers do not inherit FORCE_COLOR when NO_COLOR is active. + delete childProcessEnv.FORCE_COLOR; + } const pool = new Tinypool({ filename: workerPath, @@ -88,14 +94,17 @@ export const createWorkerPool = (options: WorkerOptions): Tinypool => { // Only add env for child_process workers ...(runtime === 'child_process' && { env: { - ...process.env, + ...childProcessEnv, // Pass worker type as environment variable for child_process workers // This is needed because workerData is not directly accessible in child_process runtime REPOMIX_WORKER_TYPE: workerType, // Pass log level as environment variable for child_process workers REPOMIX_LOG_LEVEL: logger.getLogLevel().toString(), - // Ensure color support in child_process workers - FORCE_COLOR: process.env.FORCE_COLOR || (process.stdout.isTTY ? '1' : '0'), + // Propagate color settings to child_process workers + // Respect NO_COLOR env var and --no-color flag; only set FORCE_COLOR when colors are enabled + ...(noColorEnabled + ? { NO_COLOR: '1' } + : { FORCE_COLOR: process.env.FORCE_COLOR || (process.stdout.isTTY ? '1' : '0') }), // Pass terminal capabilities TERM: process.env.TERM || 'xterm-256color', }, diff --git a/tests/cli/cliRun.test.ts b/tests/cli/cliRun.test.ts index aeab8f4c9..4ba8df322 100644 --- a/tests/cli/cliRun.test.ts +++ b/tests/cli/cliRun.test.ts @@ -417,6 +417,17 @@ describe('cliRun', () => { }); }); + describe('no-color mode', () => { + test('should accept --no-color flag without error', async () => { + const options: CliOptions = { + color: false, + }; + + await expect(runCli(['.'], process.cwd(), options)).resolves.not.toThrow(); + expect(defaultAction.runDefaultAction).toHaveBeenCalled(); + }); + }); + describe('stdout mode', () => { const originalIsTTY = process.stdout.isTTY; diff --git a/tests/shared/logger.test.ts b/tests/shared/logger.test.ts index 2b3df0578..9ead02871 100644 --- a/tests/shared/logger.test.ts +++ b/tests/shared/logger.test.ts @@ -11,6 +11,7 @@ vi.mock('picocolors', () => ({ dim: vi.fn((str) => `DIM:${str}`), blue: vi.fn((str) => `BLUE:${str}`), gray: vi.fn((str) => `GRAY:${str}`), + isColorSupported: true, }, })); @@ -110,4 +111,13 @@ describe('logger', () => { logger.info('Multiple', 'arguments', 123); expect(console.log).toHaveBeenCalledWith('CYAN:Multiple arguments 123'); }); + + describe('color support in formatArgs', () => { + it('should pass pc.isColorSupported to util.inspect for object formatting', () => { + const obj = { key: 'value' }; + logger.info('Test:', obj); + // When isColorSupported is true (mock), util.inspect should use colors + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('CYAN:Test: ')); + }); + }); }); diff --git a/tests/shared/processConcurrency.test.ts b/tests/shared/processConcurrency.test.ts index 289406ff6..3d82e4563 100644 --- a/tests/shared/processConcurrency.test.ts +++ b/tests/shared/processConcurrency.test.ts @@ -106,7 +106,6 @@ describe('processConcurrency', () => { }, env: expect.objectContaining({ REPOMIX_LOG_LEVEL: '2', - FORCE_COLOR: expect.any(String), TERM: expect.any(String), }), }); @@ -167,4 +166,110 @@ describe('processConcurrency', () => { expect(taskRunner).toHaveProperty('cleanup'); }); }); + + describe('color propagation to worker processes', () => { + beforeEach(() => { + vi.mocked(os).availableParallelism = vi.fn().mockReturnValue(4); + vi.mocked(Tinypool).mockImplementation(function (this: unknown) { + (this as Record).run = vi.fn(); + (this as Record).destroy = vi.fn(); + return this as Tinypool; + }); + }); + + it('should propagate NO_COLOR to worker when NO_COLOR env is set', () => { + const originalNoColor = process.env.NO_COLOR; + process.env.NO_COLOR = '1'; + try { + createWorkerPool({ numOfTasks: 100, workerType: 'fileProcess', runtime: 'child_process' }); + + expect(Tinypool).toHaveBeenCalledWith( + expect.objectContaining({ + env: expect.objectContaining({ + NO_COLOR: '1', + }), + }), + ); + } finally { + if (originalNoColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = originalNoColor; + } + } + }); + + it('should propagate NO_COLOR to worker when --no-color is in argv', () => { + const originalArgv = process.argv; + process.argv = [...originalArgv, '--no-color']; + try { + createWorkerPool({ numOfTasks: 100, workerType: 'fileProcess', runtime: 'child_process' }); + + expect(Tinypool).toHaveBeenCalledWith( + expect.objectContaining({ + env: expect.objectContaining({ + NO_COLOR: '1', + }), + }), + ); + } finally { + process.argv = originalArgv; + } + }); + + it('should set FORCE_COLOR when colors are not disabled', () => { + const originalNoColor = process.env.NO_COLOR; + delete process.env.NO_COLOR; + const originalArgv = process.argv; + process.argv = originalArgv.filter((arg) => arg !== '--no-color'); + try { + createWorkerPool({ numOfTasks: 100, workerType: 'fileProcess', runtime: 'child_process' }); + + expect(Tinypool).toHaveBeenCalledWith( + expect.objectContaining({ + env: expect.objectContaining({ + FORCE_COLOR: expect.any(String), + }), + }), + ); + } finally { + if (originalNoColor !== undefined) { + process.env.NO_COLOR = originalNoColor; + } + process.argv = originalArgv; + } + }); + + it('should not leak FORCE_COLOR when NO_COLOR is enabled', () => { + const originalNoColor = process.env.NO_COLOR; + const originalForceColor = process.env.FORCE_COLOR; + process.env.NO_COLOR = '1'; + process.env.FORCE_COLOR = '1'; + try { + createWorkerPool({ numOfTasks: 100, workerType: 'fileProcess', runtime: 'child_process' }); + + expect(Tinypool).toHaveBeenCalledWith( + expect.objectContaining({ + env: expect.objectContaining({ + NO_COLOR: '1', + }), + }), + ); + + const callArgs = vi.mocked(Tinypool).mock.calls.at(-1)?.[0]; + expect(callArgs?.env?.FORCE_COLOR).toBeUndefined(); + } finally { + if (originalNoColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = originalNoColor; + } + if (originalForceColor === undefined) { + delete process.env.FORCE_COLOR; + } else { + process.env.FORCE_COLOR = originalForceColor; + } + } + }); + }); });