diff --git a/src/cli/actions/migrationAction.ts b/src/cli/actions/migrationAction.ts index 236ba8e3..d98b73fd 100644 --- a/src/cli/actions/migrationAction.ts +++ b/src/cli/actions/migrationAction.ts @@ -1,6 +1,5 @@ import * as fs from 'node:fs/promises'; import path from 'node:path'; -import * as prompts from '@clack/prompts'; import pc from 'picocolors'; import { getGlobalDirectory } from '../../config/globalDirectory.js'; import { logger } from '../../shared/logger.js'; @@ -108,11 +107,12 @@ const migrateFile = async ( const exists = await fileExists(newPath); if (exists) { - const shouldOverwrite = await prompts.confirm({ + const p = await import('@clack/prompts'); + const shouldOverwrite = await p.confirm({ message: `${description} already exists at ${newPath}. Do you want to overwrite it?`, }); - if (prompts.isCancel(shouldOverwrite) || !shouldOverwrite) { + if (p.isCancel(shouldOverwrite) || !shouldOverwrite) { logger.info(`Skipping migration of ${description}`); return false; } @@ -244,12 +244,16 @@ export const runMigrationAction = async (rootDir: string): Promise { - prompts.cancel('Skill generation cancelled.'); +const onCancelOperation = (cancelFn: (message?: string) => void): never => { + cancelFn('Skill generation cancelled.'); throw new OperationCancelledError('Skill generation cancelled'); }; @@ -31,19 +30,29 @@ export const getSkillBaseDir = (cwd: string, location: SkillLocation): string => /** * Prompt user for skill location and handle overwrite confirmation. */ +// Lazy-load @clack/prompts (~16ms) to build default deps. +// Only called in production; tests always pass deps directly. +const createPromptDeps = async () => { + const p = await import('@clack/prompts'); + return { + select: p.select, + confirm: p.confirm, + isCancel: p.isCancel, + cancel: p.cancel, + access: fs.access, + rm: fs.rm, + }; +}; + export const promptSkillLocation = async ( skillName: string, cwd: string, - deps = { - select: prompts.select, - confirm: prompts.confirm, - isCancel: prompts.isCancel, - access: fs.access, - rm: fs.rm, - }, + deps = {} as Awaited>, ): Promise => { + // Resolve deps: use provided test deps or lazy-load defaults + const resolvedDeps = Object.keys(deps).length > 0 ? deps : await createPromptDeps(); // Step 1: Ask for skill location - const location = await deps.select({ + const location = await resolvedDeps.select({ message: 'Where would you like to save the skill?', options: [ { @@ -60,8 +69,8 @@ export const promptSkillLocation = async ( initialValue: 'personal' as SkillLocation, }); - if (deps.isCancel(location)) { - onCancelOperation(); + if (resolvedDeps.isCancel(location)) { + onCancelOperation(resolvedDeps.cancel); } const skillDir = path.join(getSkillBaseDir(cwd, location as SkillLocation), skillName); @@ -69,7 +78,7 @@ export const promptSkillLocation = async ( // Step 2: Check if directory exists and ask for overwrite let dirExists = false; try { - await deps.access(skillDir); + await resolvedDeps.access(skillDir); dirExists = true; } catch { // Directory doesn't exist @@ -77,16 +86,16 @@ export const promptSkillLocation = async ( if (dirExists) { const displayPath = getDisplayPath(skillDir, cwd); - const overwrite = await deps.confirm({ + const overwrite = await resolvedDeps.confirm({ message: `Skill directory already exists. Do you want to overwrite it?\n${pc.dim(`path: ${displayPath}`)}`, }); - if (deps.isCancel(overwrite) || !overwrite) { - onCancelOperation(); + if (resolvedDeps.isCancel(overwrite) || !overwrite) { + onCancelOperation(resolvedDeps.cancel); } // Remove existing directory before regeneration - await deps.rm(skillDir, { recursive: true, force: true }); + await resolvedDeps.rm(skillDir, { recursive: true, force: true }); } return { diff --git a/src/core/output/outputGenerate.ts b/src/core/output/outputGenerate.ts index 3ed68864..c5f7b454 100644 --- a/src/core/output/outputGenerate.ts +++ b/src/core/output/outputGenerate.ts @@ -1,7 +1,5 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import XMLBuilder from 'fast-xml-builder'; -import Handlebars from 'handlebars'; import type { RepomixConfigMerged } from '../../config/configSchema.js'; import { RepomixError } from '../../shared/errorHandle.js'; import { listDirectories, listFiles, searchFiles } from '../file/fileSearch.js'; @@ -22,11 +20,28 @@ import { import { getMarkdownTemplate } from './outputStyles/markdownStyle.js'; import { getPlainTemplate } from './outputStyles/plainStyle.js'; import { getXmlTemplate } from './outputStyles/xmlStyle.js'; +import { registerHandlebarsHelpers } from './outputStyleUtils.js'; + +// Lazy-load Handlebars to defer its ~25ms import cost until output generation. +// The module is loaded eagerly in the import chain (packager → produceOutput → +// outputGenerate) but only needed late in the pipeline during template rendering. +// biome-ignore lint/suspicious/noExplicitAny: Handlebars type is complex and only used internally +let handlebarsPromise: Promise | undefined; +const getHandlebars = () => { + if (!handlebarsPromise) { + handlebarsPromise = import('handlebars').then((mod) => { + registerHandlebarsHelpers(mod.default); + return mod.default; + }); + } + return handlebarsPromise; +}; // Cache for compiled Handlebars templates to avoid recompilation on every call -const compiledTemplateCache = new Map(); +// biome-ignore lint/suspicious/noExplicitAny: Handlebars TemplateDelegate type requires the full module +const compiledTemplateCache = new Map(); -const getCompiledTemplate = (style: string): Handlebars.TemplateDelegate => { +const getCompiledTemplate = async (style: string) => { const cached = compiledTemplateCache.get(style); if (cached) { return cached; @@ -47,6 +62,7 @@ const getCompiledTemplate = (style: string): Handlebars.TemplateDelegate => { throw new RepomixError(`Unsupported output style for handlebars template: ${style}`); } + const Handlebars = await getHandlebars(); const compiled = Handlebars.compile(template); compiledTemplateCache.set(style, compiled); return compiled; @@ -123,7 +139,9 @@ export const createRenderContext = (outputGeneratorContext: OutputGeneratorConte }; const generateParsableXmlOutput = async (renderContext: RenderContext): Promise => { - const xmlBuilder = new XMLBuilder({ ignoreAttributes: false }); + // Lazy-load fast-xml-builder (~3ms) — only used for parsable XML output (non-default) + const FastXmlBuilder = (await import('fast-xml-builder')).default; + const xmlBuilder = new FastXmlBuilder({ ignoreAttributes: false }); const xmlDocument = { repomix: { file_summary: renderContext.fileSummaryEnabled @@ -237,7 +255,7 @@ const generateHandlebarOutput = async ( processedFiles?: ProcessedFile[], ): Promise => { try { - const compiledTemplate = getCompiledTemplate(config.output.style); + const compiledTemplate = await getCompiledTemplate(config.output.style); return `${compiledTemplate(renderContext).trim()}\n`; } catch (error) { if (error instanceof RangeError && error.message === 'Invalid string length') { diff --git a/src/core/output/outputStyleUtils.ts b/src/core/output/outputStyleUtils.ts index a2730d9c..ed1adefa 100644 --- a/src/core/output/outputStyleUtils.ts +++ b/src/core/output/outputStyleUtils.ts @@ -1,7 +1,6 @@ /** * Shared utilities for output style generation. */ -import Handlebars from 'handlebars'; /** * Map of file extensions to syntax highlighting language names. @@ -228,14 +227,19 @@ let handlebarsHelpersRegistered = false; /** * Register common Handlebars helpers for output generation. + * Accepts a Handlebars instance to avoid requiring a top-level import of handlebars, + * which would add ~25ms to the module import chain on every CLI startup. * This function is idempotent - calling it multiple times has no effect. */ -export const registerHandlebarsHelpers = (): void => { +export const registerHandlebarsHelpers = (handlebarsInstance: { + // biome-ignore lint/suspicious/noExplicitAny: Handlebars registerHelper callback signature is loosely typed + registerHelper: (name: string, fn: (...args: any[]) => any) => void; +}): void => { if (handlebarsHelpersRegistered) { return; } - Handlebars.registerHelper('getFileExtension', (filePath: string) => { + handlebarsInstance.registerHelper('getFileExtension', (filePath: string) => { return getLanguageFromFilePath(filePath); }); diff --git a/src/core/output/outputStyles/markdownStyle.ts b/src/core/output/outputStyles/markdownStyle.ts index 050beb75..bc356386 100644 --- a/src/core/output/outputStyles/markdownStyle.ts +++ b/src/core/output/outputStyles/markdownStyle.ts @@ -1,8 +1,3 @@ -import { registerHandlebarsHelpers } from '../outputStyleUtils.js'; - -// Register Handlebars helpers (idempotent) -registerHandlebarsHelpers(); - export const getMarkdownTemplate = () => { return /* md */ ` {{#if fileSummaryEnabled}} diff --git a/src/core/packager.ts b/src/core/packager.ts index afefb272..26c3c1a2 100644 --- a/src/core/packager.ts +++ b/src/core/packager.ts @@ -17,7 +17,7 @@ import { prefetchFileChangeCounts } from './output/outputSort.js'; import { produceOutput } from './packager/produceOutput.js'; import type { SuspiciousFileResult } from './security/securityCheck.js'; import { validateFileSafety } from './security/validateFileSafety.js'; -import { packSkill } from './skill/packSkill.js'; +import type { PackSkillParams } from './skill/packSkill.js'; export interface PackResult { totalFiles: number; @@ -48,7 +48,13 @@ const defaultDeps = { sortPaths, getGitDiffs, getGitLogs, - packSkill, + // Lazy-load packSkill to defer importing the skill module chain + // (skillSectionGenerators, skillStyle → Handlebars), which adds ~25ms + // to module loading. Only used when --skill-generate is active (non-default). + packSkill: async (params: PackSkillParams) => { + const { packSkill } = await import('./skill/packSkill.js'); + return packSkill(params); + }, prefetchFileChangeCounts, }; diff --git a/src/core/skill/skillSectionGenerators.ts b/src/core/skill/skillSectionGenerators.ts index 54d8e413..33c76d4d 100644 --- a/src/core/skill/skillSectionGenerators.ts +++ b/src/core/skill/skillSectionGenerators.ts @@ -3,8 +3,10 @@ import { generateTreeStringWithLineCounts } from '../file/fileTreeGenerate.js'; import type { RenderContext } from '../output/outputGeneratorTypes.js'; import { registerHandlebarsHelpers } from '../output/outputStyleUtils.js'; -// Register Handlebars helpers (idempotent) -registerHandlebarsHelpers(); +// Register Handlebars helpers (idempotent). +// This module's Handlebars import cost is only paid when --skill-generate +// is active, because packager.ts lazy-loads packSkill via dynamic import. +registerHandlebarsHelpers(Handlebars); /** * Generates the summary section for skill output. diff --git a/tests/cli/prompts/skillPrompts.test.ts b/tests/cli/prompts/skillPrompts.test.ts index 1d42a061..69cf8371 100644 --- a/tests/cli/prompts/skillPrompts.test.ts +++ b/tests/cli/prompts/skillPrompts.test.ts @@ -20,6 +20,7 @@ const createMockDeps = (overrides: { select: vi.fn().mockResolvedValue(overrides.selectValue), confirm: vi.fn().mockResolvedValue(overrides.confirmValue), isCancel: overrides.isCancelFn as (value: unknown) => value is symbol, + cancel: vi.fn(), access: overrides.accessRejects ? vi.fn().mockRejectedValue(new Error('ENOENT')) : vi.fn().mockResolvedValue(undefined), diff --git a/tests/core/output/outputStyles/markdownStyle.test.ts b/tests/core/output/outputStyles/markdownStyle.test.ts index 8344a8da..45a4417b 100644 --- a/tests/core/output/outputStyles/markdownStyle.test.ts +++ b/tests/core/output/outputStyles/markdownStyle.test.ts @@ -1,6 +1,10 @@ import Handlebars from 'handlebars'; import { describe, expect, test } from 'vitest'; import { getMarkdownTemplate } from '../../../../src/core/output/outputStyles/markdownStyle.js'; +import { registerHandlebarsHelpers } from '../../../../src/core/output/outputStyleUtils.js'; + +// Register helpers that are now lazy-loaded in production code +registerHandlebarsHelpers(Handlebars); describe('markdownStyle', () => { describe('getMarkdownTemplate', () => {