From 9da6be416fd8c19ffbe68d654078ee2bee94a4fa Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Mon, 6 Apr 2026 19:09:20 +0900 Subject: [PATCH 1/4] feat(core): Add progressCallback parameter to runDefaultAction Allow callers to receive detailed progress messages from pack() (e.g., "Searching for files...", "Collecting files...", "Generating output...") by passing an optional progressCallback. The callback is invoked alongside the existing spinner updates, so CLI behavior is unchanged. This enables programmatic consumers like the website server to stream fine-grained progress to clients. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/actions/defaultAction.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/cli/actions/defaultAction.ts b/src/cli/actions/defaultAction.ts index 3cadb9b11..0eb3364bb 100644 --- a/src/cli/actions/defaultAction.ts +++ b/src/cli/actions/defaultAction.ts @@ -13,6 +13,7 @@ import { generateDefaultSkillName } from '../../core/skill/skillUtils.js'; import { RepomixError, rethrowValidationErrorIfZodError } from '../../shared/errorHandle.js'; import { logger } from '../../shared/logger.js'; import { splitPatterns } from '../../shared/patternUtils.js'; +import type { RepomixProgressCallback } from '../../shared/types.js'; import { reportResults } from '../cliReport.js'; import { Spinner } from '../cliSpinner.js'; import { promptSkillLocation, resolveAndPrepareSkillDir } from '../prompts/skillPrompts.js'; @@ -28,6 +29,7 @@ export const runDefaultAction = async ( directories: string[], cwd: string, cliOptions: CliOptions, + progressCallback?: RepomixProgressCallback, ): Promise => { logger.trace('Loaded CLI options:', cliOptions); @@ -113,16 +115,12 @@ export const runDefaultAction = async ( const targetPaths = stdinFilePaths ? [cwd] : directories.map((directory) => path.resolve(cwd, directory)); - packResult = await pack( - targetPaths, - config, - (message) => { - spinner.update(message); - }, - {}, - stdinFilePaths, - packOptions, - ); + const handleProgress: RepomixProgressCallback = (message) => { + spinner.update(message); + progressCallback?.(message); + }; + + packResult = await pack(targetPaths, config, handleProgress, {}, stdinFilePaths, packOptions); spinner.succeed('Packing completed successfully!'); } catch (error) { From d74e3e9ad51ceca8a13b13d76c15f027bfa03762 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Mon, 6 Apr 2026 19:16:01 +0900 Subject: [PATCH 2/4] fix(core): Isolate progressCallback failures from pack flow Wrap progressCallback invocation with try/catch and Promise.catch to prevent unhandled rejections from crashing the process when an async callback is passed. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/actions/defaultAction.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/cli/actions/defaultAction.ts b/src/cli/actions/defaultAction.ts index 0eb3364bb..40958563f 100644 --- a/src/cli/actions/defaultAction.ts +++ b/src/cli/actions/defaultAction.ts @@ -117,7 +117,15 @@ export const runDefaultAction = async ( const handleProgress: RepomixProgressCallback = (message) => { spinner.update(message); - progressCallback?.(message); + if (progressCallback) { + try { + Promise.resolve(progressCallback(message)).catch((error) => { + logger.trace('progressCallback error:', error); + }); + } catch (error) { + logger.trace('progressCallback error:', error); + } + } }; packResult = await pack(targetPaths, config, handleProgress, {}, stdinFilePaths, packOptions); From 01bb3312389d6a699fdc0a6a127a4596fb76c2f8 Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Mon, 6 Apr 2026 19:22:12 +0900 Subject: [PATCH 3/4] fix(core): Address PR review feedback - Update RepomixProgressCallback type to void | Promise to explicitly indicate async callback support - Remove redundant outer try-catch since Promise.resolve() handles both sync and async returns Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/actions/defaultAction.ts | 8 ++------ src/shared/types.ts | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/cli/actions/defaultAction.ts b/src/cli/actions/defaultAction.ts index 40958563f..57d552999 100644 --- a/src/cli/actions/defaultAction.ts +++ b/src/cli/actions/defaultAction.ts @@ -118,13 +118,9 @@ export const runDefaultAction = async ( const handleProgress: RepomixProgressCallback = (message) => { spinner.update(message); if (progressCallback) { - try { - Promise.resolve(progressCallback(message)).catch((error) => { - logger.trace('progressCallback error:', error); - }); - } catch (error) { + Promise.resolve(progressCallback(message)).catch((error) => { logger.trace('progressCallback error:', error); - } + }); } }; diff --git a/src/shared/types.ts b/src/shared/types.ts index 6a30d6d9b..e0c49862b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1 +1 @@ -export type RepomixProgressCallback = (message: string) => void; +export type RepomixProgressCallback = (message: string) => void | Promise; From cb00a7cbc0ddfac4a54e65673846acf5b695cdad Mon Sep 17 00:00:00 2001 From: Kazuki Yamada Date: Mon, 6 Apr 2026 19:33:23 +0900 Subject: [PATCH 4/4] test(core): Add test coverage for progressCallback in runDefaultAction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three test cases for handleProgress: - Callback is invoked with progress messages from pack() - Async callback rejection is isolated (pack completes, error logged) - Spinner still updates when callback throws synchronously Also restore the outer try-catch for sync errors — Promise.resolve() only catches async rejections, not synchronous throws from the callback invocation. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/actions/defaultAction.ts | 8 ++- tests/cli/actions/defaultAction.test.ts | 90 +++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/src/cli/actions/defaultAction.ts b/src/cli/actions/defaultAction.ts index 57d552999..40958563f 100644 --- a/src/cli/actions/defaultAction.ts +++ b/src/cli/actions/defaultAction.ts @@ -118,9 +118,13 @@ export const runDefaultAction = async ( const handleProgress: RepomixProgressCallback = (message) => { spinner.update(message); if (progressCallback) { - Promise.resolve(progressCallback(message)).catch((error) => { + try { + Promise.resolve(progressCallback(message)).catch((error) => { + logger.trace('progressCallback error:', error); + }); + } catch (error) { logger.trace('progressCallback error:', error); - }); + } } }; diff --git a/tests/cli/actions/defaultAction.test.ts b/tests/cli/actions/defaultAction.test.ts index 920e94631..748993e39 100644 --- a/tests/cli/actions/defaultAction.test.ts +++ b/tests/cli/actions/defaultAction.test.ts @@ -6,6 +6,7 @@ import * as configLoader from '../../../src/config/configLoad.js'; import * as fileStdin from '../../../src/core/file/fileStdin.js'; import * as packageJsonParser from '../../../src/core/file/packageJsonParse.js'; import * as packager from '../../../src/core/packager.js'; +import * as loggerModule from '../../../src/shared/logger.js'; import { createMockConfig } from '../../testing/testUtils.js'; vi.mock('../../../src/core/packager'); @@ -156,6 +157,95 @@ describe('defaultAction', () => { expect(mockSpinner.fail).toHaveBeenCalledWith('Error during packing'); }); + describe('progressCallback', () => { + it('should forward progress messages to the provided callback', async () => { + // Configure pack mock to invoke its 3rd argument (progressCallback) + vi.mocked(packager.pack).mockImplementation(async (_paths, _config, progressCallback = () => {}) => { + progressCallback('Searching for files...'); + progressCallback('Processing files...'); + return { + totalFiles: 10, + totalCharacters: 1000, + totalTokens: 200, + fileCharCounts: {}, + fileTokenCounts: {}, + suspiciousFilesResults: [], + suspiciousGitDiffResults: [], + suspiciousGitLogResults: [], + processedFiles: [], + safeFilePaths: [], + gitDiffTokenCount: 0, + gitLogTokenCount: 0, + skippedFiles: [], + }; + }); + + const callback = vi.fn(); + await runDefaultAction(['.'], process.cwd(), {}, callback); + + expect(callback).toHaveBeenCalledWith('Searching for files...'); + expect(callback).toHaveBeenCalledWith('Processing files...'); + }); + + it('should isolate async callback errors without affecting pack flow', async () => { + vi.mocked(packager.pack).mockImplementation(async (_paths, _config, progressCallback = () => {}) => { + progressCallback('test message'); + // Allow microtask to process the rejected promise + await new Promise((resolve) => setTimeout(resolve, 10)); + return { + totalFiles: 10, + totalCharacters: 1000, + totalTokens: 200, + fileCharCounts: {}, + fileTokenCounts: {}, + suspiciousFilesResults: [], + suspiciousGitDiffResults: [], + suspiciousGitLogResults: [], + processedFiles: [], + safeFilePaths: [], + gitDiffTokenCount: 0, + gitLogTokenCount: 0, + skippedFiles: [], + }; + }); + + const rejectingCallback = vi.fn().mockRejectedValue(new Error('callback error')); + const result = await runDefaultAction(['.'], process.cwd(), {}, rejectingCallback); + + expect(result.packResult.totalFiles).toBe(10); + expect(loggerModule.logger.trace).toHaveBeenCalledWith('progressCallback error:', expect.any(Error)); + }); + + it('should still update spinner even when callback throws synchronously', async () => { + vi.mocked(packager.pack).mockImplementation(async (_paths, _config, progressCallback = () => {}) => { + progressCallback('test message'); + return { + totalFiles: 10, + totalCharacters: 1000, + totalTokens: 200, + fileCharCounts: {}, + fileTokenCounts: {}, + suspiciousFilesResults: [], + suspiciousGitDiffResults: [], + suspiciousGitLogResults: [], + processedFiles: [], + safeFilePaths: [], + gitDiffTokenCount: 0, + gitLogTokenCount: 0, + skippedFiles: [], + }; + }); + + const throwingCallback = vi.fn().mockImplementation(() => { + throw new Error('sync error'); + }); + await runDefaultAction(['.'], process.cwd(), {}, throwingCallback); + + // Spinner should still be updated despite callback failure + expect(mockSpinner.update).toHaveBeenCalledWith('test message'); + }); + }); + describe('buildCliConfig', () => { it('should handle custom include patterns', () => { const options = {