Skip to content

Advanced Lazy Loading and Sub-Commands

This guide explores advanced patterns for implementing lazy loading with sub-commands in Gunshi, based on real-world implementations like pnpmc.

Why Use Advanced Lazy Loading?

While Gunshi's basic lazy loading (covered in Lazy & Async) is powerful, large CLI applications with many sub-commands can benefit from more advanced patterns:

  • Modular Organization: Separate commands into independent packages or modules
  • On-Demand Loading: Load command implementations only when explicitly invoked
  • Reduced Memory Footprint: Minimize memory usage by loading only what's needed
  • Faster Startup: Improve CLI startup time by deferring command loading
  • Better Maintainability: Isolate command implementations for easier maintenance

Real-World Example: pnpmc Pattern

The pnpmc project (PNPM Catalogs Tooling) demonstrates an effective pattern for organizing a CLI with lazy-loaded sub-commands:

  1. Bundled Metadata, Lazy-Loaded Implementations:

    • Command metadata (name, description, arguments) is imported directly and bundled with the main CLI package
    • Only the command runners (implementations) are lazy-loaded when executed
    • This allows displaying help information for all commands without loading implementations
  2. Modular Package Structure:

    • Command metadata is exposed from separate packages via meta.js files and imported directly
    • Command implementations are in separate packages and loaded on-demand
    • This separation enables showing usage via --help without loading all command code
  3. Custom Loader Implementation:

    • A custom loader dynamically imports only the command runners when needed
    • Error handling for module resolution failures

Let's explore how to implement this pattern in your own CLI applications.

Implementation Pattern

1. Project Structure

For a CLI with multiple sub-commands, consider organizing your code like this:

sh
my-cli/
├── packages/
   ├── cli/                 # Main CLI package
   ├── src/
   ├── commands.ts  # Command definitions
   ├── loader.ts    # Custom loader
   └── index.ts     # CLI entry point
   ├── command-a/           # Command A package
   ├── src/
   ├── meta.ts      # Command metadata
   └── index.ts     # Command implementation
   └── command-b/           # Command B package
       ├── src/
   ├── meta.ts      # Command metadata
   └── index.ts     # Command implementation

2. Command Metadata

Define command metadata in a separate file (e.g., meta.ts):

ts
// packages/command-a/src/meta.ts
export default {
  name: 'command-a',
  description: 'Performs action A',
  args: {
    input: {
      type: 'string',
      short: 'i',
      description: 'Input file'
    },
    output: {
      type: 'string',
      short: 'o',
      description: 'Output file'
    }
  }
}

3. Command Implementation

Implement the command in a separate file (e.g., index.ts):

ts
// packages/command-a/src/index.ts
import type { CommandContext } from 'gunshi'
import meta from './meta'

export const run = async (ctx: CommandContext<typeof meta.args>) => {
  const { input, output } = ctx.values
  console.log(`Processing ${input} to ${output}`)
  // Command implementation...
}

4. Custom Loader

Create a custom loader to dynamically import command implementations:

ts
// packages/cli/src/loader.ts
import type { Args, CommandRunner } from 'gunshi'

export async function load<A extends Args = Args>(pkg: string): Promise<CommandRunner<A>> {
  // Dynamic import of the command package
  try {
    const mod = await import(pkg)
    return mod.default || mod.run
  } catch (error) {
    // Handle module not found errors
    if (isErrorModuleNotFound(error)) {
      console.error(`Command module '${pkg}' not found`)
      return null
    }
    throw error
  }
}

function isErrorModuleNotFound(e: unknown): boolean {
  return (
    e instanceof Error &&
    'code' in e &&
    typeof e.code === 'string' &&
    e.code === 'ERR_MODULE_NOT_FOUND'
  )
}

5. Command Definitions

Define your commands using Gunshi's lazy function and your custom loader:

ts
// packages/cli/src/commands.ts
import { lazy } from 'gunshi/definition'
import { load } from './loader'

// Import command metadata directly - these are bundled with your CLI
import metaCommandA from 'command-a/meta'
import metaCommandB from 'command-b/meta'

// Create lazy-loaded commands
// Note: Only the implementation (runner) is lazy-loaded, not the metadata
export const commandALazy = lazy(
  // This function is only called when the command is executed
  async () => await load('command-a'),
  // Metadata is provided directly and available immediately
  metaCommandA
)

export const commandBLazy = lazy(async () => await load('command-b'), metaCommandB)

// Create a map of commands
export const commands = new Map()
commands.set(metaCommandA.name, commandALazy)
commands.set(metaCommandB.name, commandBLazy)

This approach ensures that:

  1. Command metadata is immediately available for generating help text
  2. Command implementations are only loaded when the command is actually executed

6. CLI Entry Point

Set up your CLI entry point to use the lazy-loaded commands:

ts
// packages/cli/src/index.ts
import { cli } from 'gunshi'
import { commands, commandALazy } from './commands'

async function main() {
  // Load package.json for version info
  const pkgJsonModule = await import('./package.json', { with: { type: 'json' } })
  const pkgJson = pkgJsonModule.default

  // Run the CLI with lazy-loaded commands
  await cli(process.argv.slice(2), commandALazy, {
    name: 'my-cli',
    version: pkgJson.version,
    description: 'My CLI application',
    subCommands: commands
  })
}

await main()

Advanced Techniques

On-Demand Sub-Command Loading

For CLIs with many sub-commands, you can implement on-demand sub-command loading:

ts
// packages/cli/src/commands.ts
import { lazy } from 'gunshi/definition'
import { load } from './loader'

// Function to create a lazy command
function createLazyCommand(name: string) {
  return lazy(
    async () => {
      // Dynamically import metadata and implementation
      const meta = await import(`${name}/meta`).then(m => m.default || m)
      return await load(name)
    },
    { name } // Minimal metadata, rest will be loaded on demand
  )
}

// Create commands map with factory function
export const commands = new Map([
  ['command-a', createLazyCommand('command-a')],
  ['command-b', createLazyCommand('command-b')]
  // Add more commands as needed
])

Package Manager Integration

For CLI tools that integrate with package managers (like pnpmc does with pnpm), you can enhance your loader:

ts
// packages/cli/src/loader.ts
import { detect, resolveCommand } from 'package-manager-detector'
import { x } from 'tinyexec'
import type { Args, CommandContext, CommandRunner } from 'gunshi'

export async function load<A extends Args = Args>(pkg: string): Promise<CommandRunner<A>> {
  // Detect package manager (npm, yarn, pnpm, etc.)
  const pm = await detect()
  if (pm === null) {
    throw new Error('Fatal Error: Cannot detect package manager')
  }

  // Return a command runner function
  async function runner<A extends Args>(ctx: CommandContext<A>): Promise<void> {
    // Construct the sub-command
    const subCommand = ctx.env.version ? `${pkg}@${ctx.env.version}` : pkg

    // Resolve the command using the package manager
    const resolvedCommand = resolveCommand(pm.agent, 'execute', [subCommand, ...ctx._.slice(1)])
    if (resolvedCommand === null) {
      throw new Error(`Fatal Error: Cannot resolve command '${ctx._[0]}'`)
    }

    // Execute the command
    await x(resolvedCommand.command, resolvedCommand.args, {
      nodeOptions: {
        cwd: ctx.env.cwd,
        stdio: 'inherit',
        env: Object.assign({}, process.env, { CLI_LOADER: 'true' })
      }
    })
  }

  return runner
}

Performance Considerations

When implementing advanced lazy loading, consider these performance optimizations:

  1. Metadata Size: Keep command metadata small since it's bundled with your CLI
  2. Metadata/Implementation Separation: Clearly separate what's needed for help text vs. execution
  3. Dependency Management: Keep implementation dependencies isolated to each command package
  4. Caching: Cache loaded command implementations to avoid repeated imports
  5. Error Handling: Implement robust error handling for implementation loading failures
  6. Startup Time: Measure and optimize CLI startup time by minimizing what's loaded initially

Type Safety

Maintain type safety with TypeScript when implementing advanced lazy loading:

ts
// packages/cli/src/commands.ts
import { lazy, define } from 'gunshi/definition'
import type { CommandRunner } from 'gunshi'
import { load } from './loader'

// Define command metadata with type safety
const metaCommandA = define({
  name: 'command-a',
  description: 'Performs action A',
  args: {
    input: {
      type: 'string',
      short: 'i',
      description: 'Input file'
    }
  }
})

// Type for command arguments
type CommandAArgs = NonNullable<typeof metaCommandA.args>

// Create type-safe lazy command
const commandALazy = lazy<CommandAArgs>(async (): Promise<CommandRunner<CommandAArgs>> => {
  return await load<CommandAArgs>('command-a')
}, metaCommandA)

Conclusion

Advanced lazy loading with sub-commands allows you to build scalable, maintainable CLI applications with optimal performance. By bundling command metadata with your main CLI while lazy-loading command implementations, you can create complex CLIs that:

  1. Start up quickly with minimal initial loading
  2. Display comprehensive help information for all commands
  3. Only load command implementations when they're actually executed

The pattern demonstrated by pnpmc provides a solid foundation for organizing your CLI code, which you can adapt and extend to meet your specific requirements.

Released under the MIT License.