Skip to content

Gunshi v0.27 Release Notes

🌟 Features

Plugin System

Gunshi v0.27 introduces a powerful plugin system that enables modular CLI architecture. Plugins can extend command functionality, add global options, and provide cross-cutting concerns like internationalization and completion.

ts
import { cli } from 'gunshi'
import i18n, { defineI18nWithTypes, pluginId as i18nId } from '@gunshi/plugin-i18n'
import completion from '@gunshi/plugin-completion'
import type { I18nExtension, PluginId as I18nPluginId } from '@gunshi/plugin-i18n'

// Type-safe command definition with plugin extensions
const command = defineI18nWithTypes<{ extensions: Record<I18nPluginId, I18nExtension> }>()({
  name: 'app',
  // Provide translation resources for i18n plugin
  resource: locale => {
    if (locale.toString() === 'ja-JP') {
      return { greeting: 'こんにちは' }
    }
    return { greeting: 'Hello' }
  },
  run: ctx => {
    // Plugins automatically extend the context
    console.log(ctx.extensions[i18nId].translate('greeting'))
  }
})

await cli(process.argv.slice(2), command, {
  name: 'my-app',
  version: '1.0.0',
  plugins: [i18n(), completion()]
})

Plugins can extend the CommandContext by adding their own properties to ctx.extensions. Each plugin's extension is namespaced using its plugin ID, preventing conflicts and ensuring type safety:

  • Extensions are accessed via ctx.extensions[pluginId]
  • Plugin IDs should be imported to avoid hardcoding strings
  • TypeScript provides full type inference for extensions when using define<Record<PluginId, Extension>>()
  • Multiple plugins can coexist, each providing their own extensions

In the example above, the i18n plugin extends ctx.extensions with internationalization functionality accessible via ctx.extensions[i18nId]. This provides methods like translate() and properties like locale to all commands, enabling consistent internationalization across your CLI application.

Official Plugin Ecosystem

Gunshi v0.27 comes with a comprehensive set of official plugins that work seamlessly with the new plugin system:

Built-in Plugins (included in gunshi package)

These plugins are automatically included when using the standard cli() function from gunshi:

  • @gunshi/plugin-global: Adds --help and --version options to all commands automatically

    • Provides showVersion(), showHeader(), showUsage(), and showValidationErrors() methods
    • Intercepts command execution to handle help/version display
  • @gunshi/plugin-renderer: Automatic rendering of usage, help text, and validation errors

    • Integrates with i18n plugin for localized messages
    • Customizable rendering for headers, usage, and error messages
    • Smart formatting for arguments, options, and subcommands

Optional Plugins

Install these plugins separately based on your needs:

  • gunshi/plugin-i18n: Comprehensive internationalization support

    sh
    npm install @gunshi/plugin-i18n
    • Type-safe translation functions with key completion
    • Dynamic resource loading per command
    • Custom translation adapters
    • Built-in namespace management
  • @gunshi/plugin-completion: Shell completion support

    sh
    npm install @gunshi/plugin-completion
    • Support for bash, zsh, fish, and PowerShell
    • Custom completion handlers for dynamic suggestions
    • Automatic complete subcommand generation
    • Integration with i18n for localized descriptions

@gunshi/definition - Command Definition Utilities

New standalone package for creating reusable, distributable commands:

sh
npm install @gunshi/definition

This package enables you to create standalone commands that can be published as libraries and shared across multiple CLI applications:

js
// my-shared-commands/build.js
import { define } from '@gunshi/definition'

// Create a standalone command that can be imported by any Gunshi CLI
export const buildCommand = define({
  name: 'build',
  args: {
    watch: { type: 'boolean', default: false },
    output: { type: 'string', required: true }
  },
  run: ctx => {
    if (ctx.values.watch) {
      console.log(`Watching and building to ${ctx.values.output}`)
    }
  }
})

// my-shared-commands/analyze.js
import { lazy } from '@gunshi/definition'

// Export a lazy-loaded command for better performance
export const analyzeCommand = lazy(async () => {
  // Heavy imports only when command is actually used
  const { analyze } = await import('./heavy-analyzer.js')

  return {
    name: 'analyze',
    run: ctx => analyze(ctx.values)
  }
})

Now these commands can be imported and used in any Gunshi CLI:

js
// app-cli/index.js
import { cli } from 'gunshi'
import { buildCommand, analyzeCommand } from 'my-shared-commands'

await cli(process.argv.slice(2), buildCommand, {
  name: 'app',
  subCommands: {
    build: buildCommand,
    analyze: analyzeCommand
  }
})

Benefits:

  • Standalone commands: Create reusable commands that can be published as npm packages
  • Framework-agnostic distribution: Commands defined with @gunshi/definition can be imported by any Gunshi CLI
  • Smaller bundles: Separate from main gunshi package for optimal tree-shaking
  • Full type safety: Complete TypeScript inference for arguments and extensions (see Type Safety Enhancements for details)
  • Mix and match: Combine commands from multiple packages to build your CLI

@gunshi/bone - Minimal CLI Package

A lightweight alternative to the main gunshi package, without any built-in plugins:

sh
npm install @gunshi/bone
js
import { cli } from '@gunshi/bone'
import customPlugin from './my-custom-plugin.js'

// No default plugins - you have full control
await cli(process.argv.slice(2), command, {
  name: 'my-minimal-cli',
  plugins: [customPlugin()] // Only the plugins you need
})

Use cases:

  • When you need complete control over plugin composition
  • For minimal bundle size in size-critical applications
  • When built-in plugins conflict with your custom implementation
  • For embedded CLIs where every byte counts

Fallback to Entry Command

New fallbackToEntry option enables graceful handling of unknown subcommands:

js
const mainCommand = {
  name: 'tool',
  args: {
    file: { type: 'string', positional: true }
  },
  run: ctx => {
    // Process file when no subcommand is provided
    console.log(`Processing ${ctx.values.file}`)
  }
}

await cli(process.argv.slice(2), mainCommand, {
  name: 'tool',
  fallbackToEntry: true, // Enable fallback
  subCommands: {
    convert: convertCommand,
    validate: validateCommand
  }
})

// Usage:
// tool convert file.txt  -> Runs convert subcommand
// tool file.txt          -> Falls back to main command
// tool unknown file.txt  -> Falls back to main command (with v0.27)

This feature is particularly useful for:

  • Git-style CLIs where the main command has its own functionality
  • Backward compatibility when adding subcommands to existing CLIs
  • Creating more forgiving user experiences

Subcommand Object Style

Subcommands can now be defined as plain objects (Record) in addition to Map, providing better organization for complex CLIs:

js
// Define subcommands
const addCommand = {
  name: 'add',
  description: 'Add file contents to the index',
  run: ctx => console.log('Adding files...')
}

const commitCommand = {
  name: 'commit',
  description: 'Record changes to the repository',
  run: ctx => console.log('Committing...')
}

// Pass subcommands as object to CLI options
await cli(process.argv.slice(2), mainCommand, {
  name: 'git',
  version: '1.0.0',
  subCommands: {
    add: addCommand,
    commit: commitCommand
  }
})

Explicit Argument Detection

New feature to detect whether arguments were explicitly provided by the user or using default values:

js
const command = {
  name: 'deploy',
  args: {
    verbose: {
      type: 'boolean',
      default: false,
      description: 'Enable verbose output'
    },
    output: {
      type: 'string',
      default: 'output.txt',
      description: 'Output file path'
    }
  },
  run: ctx => {
    // Check if arguments were explicitly provided
    if (ctx.explicit.output) {
      console.log('Output file explicitly specified:', ctx.values.output)
    } else {
      console.log('Using default output file:', ctx.values.output)
    }

    // Useful for configuration overrides
    if (!ctx.explicit.verbose && ctx.values.verbose) {
      console.log('Verbose mode enabled by config file')
    }
  }
}

The ctx.explicit object indicates for each argument:

  • true: The argument was explicitly provided via command line
  • false: The argument uses a default value or is undefined

Command Lifecycle Hooks

New hooks for fine-grained control over command execution, available in CLI options:

js
const command = {
  name: 'backup',
  run: ctx => {
    // Main backup logic
    console.log('Running backup...')
  }
}

await cli(process.argv.slice(2), command, {
  name: 'backup-tool',
  version: '1.0.0',
  onBeforeCommand: async ctx => {
    console.log('Preparing backup...')
  },
  onAfterCommand: async (ctx, result) => {
    console.log('Backup completed!')
    if (result) console.log('Result:', result)
  },
  onErrorCommand: async (ctx, error) => {
    console.error('Backup failed:', error)
    // Custom error handling logic
  }
})

Custom Rendering Control

New rendering options allow fine-grained control over how commands display their output. Each command can customize or disable specific parts of the UI:

js
const command = {
  name: 'wizard',
  rendering: {
    // Custom header with emoji and version
    header: async ctx => `🚀 ${ctx.name} v${ctx.version}`,

    // Disable default usage (set to null)
    usage: null,

    // Custom error formatting
    validationErrors: async (ctx, error) => `❌ Error: ${error.message}`
  },
  run: ctx => {
    // Command logic
  }
}

This feature enables:

  • Per-command customization: Each command can have its own UI style
  • Selective rendering: Disable specific parts (header, usage, errors) by setting them to null
  • Async rendering: Support for async functions to fetch dynamic content
  • Full context access: Renderers receive the complete command context for flexible output generation

⚡ Improvement Features

Type Safety Enhancements

v0.27 brings comprehensive type safety improvements to all core APIs through generic type parameters, enabling full TypeScript inference for arguments and plugin extensions.

Enhanced define() API

The define function now accepts type parameters for type-safe command definitions with plugin extensions:

ts
import { define } from 'gunshi'
import logger, { pluginId as loggerId } from '@your-company/plugin-logger'
import auth, { pluginId as authId } from '@your-company/plugin-auth'

import type { LoggerExtension, PluginId as LoggerId } from '@your-company/plugin-logger'
import type { AuthExtension, PluginId as AuthId } from '@your-company/plugin-auth'

// Type-safe command with plugin extensions
const deployCommand = define<{
  extensions: Record<LoggerId, LoggerExtension> & Record<AuthId, AuthExtension>
}>({
  name: 'deploy',
  run: ctx => {
    // Plugin extensions are fully typed
    ctx.extensions[loggerId]?.log('Starting deployment...')

    if (!ctx.extensions[authId]?.isAuthenticated()) {
      throw new Error('Authentication required')
    }

    const user = ctx.extensions[authId]?.getUser()
    ctx.extensions[loggerId]?.info(`Deploying as ${user?.name}`)
  }
})

New defineWithTypes() API

For better type inference with plugin extensions, v0.27 introduces defineWithTypes() - a curried function that separates type specification from command definition:

ts
import { defineWithTypes } from 'gunshi'

// Define your extensions type first
type MyExtensions = {
  logger: { log: (msg: string) => void }
  auth: { isAuthenticated: () => boolean; getUser: () => User }
}

// Use defineWithTypes - specify only extensions, args are inferred!
const command = defineWithTypes<{ extensions: MyExtensions }>()({
  name: 'deploy',
  args: {
    env: { type: 'string', required: true },
    force: { type: 'boolean', default: false }
  },
  run: ctx => {
    // ctx.values is automatically inferred as { env: string; force?: boolean }
    // ctx.extensions is typed as MyExtensions
    if (!ctx.extensions.auth?.isAuthenticated()) {
      throw new Error('Authentication required')
    }

    ctx.extensions.logger?.log(`Deploying to ${ctx.values.env}`)
  }
})

Benefits over standard define():

  • Cleaner syntax when specifying extensions - no need for complex generic type parameters
  • Better IDE support with curried function pattern
  • Arguments are automatically inferred from the definition

Enhanced lazy() API

The lazy function supports type parameters for lazy-loaded commands with extensions:

ts
import { lazy } from 'gunshi'
import { pluginId as dbId } from '@your-company/plugin-database'
import { pluginId as cacheId } from '@your-company/plugin-cache'

import type { DatabaseExtension, PluginId as DbId } from '@your-company/plugin-database'
import type { CacheExtension, PluginId as CacheId } from '@your-company/plugin-cache'

// Type-safe lazy loading with plugin extensions
const heavyCommand = lazy<{
  extensions: Record<DbId, DatabaseExtension> & Record<CacheId, CacheExtension>
}>(async () => {
  // Command is loaded only when needed
  return {
    name: 'process',
    run: async ctx => {
      // Extensions are typed even in lazy-loaded commands
      const cached = await ctx.extensions[cacheId]?.get('data')
      if (cached) return cached

      const result = await ctx.extensions[dbId]?.query('SELECT * FROM data')
      await ctx.extensions[cacheId]?.set('data', result)
      return result
    }
  }
})

New lazyWithTypes() API

Similar to defineWithTypes(), the new lazyWithTypes() function provides better type inference for lazy-loaded commands with extensions:

ts
import { lazyWithTypes } from 'gunshi'

type BuildExtensions = {
  logger: { log: (msg: string) => void }
  metrics: { track: (event: string) => void }
}

// Define args separately for reusability
const buildArgs = {
  target: { type: 'enum', required: true, choices: ['dev', 'prod'] },
  minify: { type: 'boolean', default: false }
} as const

// Use lazyWithTypes with both args and extensions
const buildCommand = lazyWithTypes<{
  args: typeof buildArgs
  extensions: BuildExtensions
}>()(
  async () => {
    // Heavy dependencies loaded only when needed
    const { buildProject } = await import('./heavy-build-utils')

    return async ctx => {
      // ctx.values inferred from args
      const { target, minify } = ctx.values

      // Extensions fully typed
      ctx.extensions.logger?.log(`Building for ${target}...`)
      ctx.extensions.metrics?.track('build.started')

      await buildProject({ target, minify })
    }
  },
  {
    name: 'build',
    description: 'Build the project',
    args: buildArgs
  }
)

Benefits:

  • Type-safe lazy loading with plugin extensions
  • Separates type declaration from implementation
  • Supports both args and extensions type parameters
  • Maintains all benefits of lazy loading (performance, code splitting)

Enhanced cli() API

The cli function accepts type parameters for the entire CLI application's extensions:

ts
import { cli } from 'gunshi'
import i18n, { pluginId as i18nId } from '@gunshi/plugin-i18n'
import metrics, { pluginId as metricsId } from '@your-company/plugin-metrics'

import type { I18nExtension, PluginId as I18nId } from '@gunshi/plugin-i18n'
import type { MetricsExtension, PluginId as MetricsId } from '@your-company/plugin-metrics'

// Type-safe CLI with multiple plugin extensions
await cli<{ extensions: Record<I18nId, I18nExtension> & Record<MetricsId, MetricsExtension> }>(
  process.argv.slice(2),
  async ctx => {
    // All plugin extensions are available and typed
    const greeting = ctx.extensions[i18nId]?.translate('welcome')
    console.log(greeting)

    // Track CLI usage
    ctx.extensions[metricsId]?.track('cli.started', {
      command: ctx.name,
      locale: ctx.extensions[i18nId]?.locale.toString()
    })
  },
  {
    name: 'my-cli',
    plugins: [i18n(), metrics()]
  }
)

These type safety improvements ensure that:

  • Command arguments are validated at compile time
  • Plugin extensions are properly typed and accessible
  • Typos and incorrect property access are caught before runtime
  • IDE autocompletion works seamlessly across your CLI application

💥 Breaking Changes

Internationalization migration

The built-in i18n support in CLI options has been moved to the official i18n plugin:

Before (v0.26):

js
import { cli } from 'gunshi'

const command = {
  name: 'app',
  resource: async ctx => {
    // Return translations based on locale
    if (ctx.locale.toString() === 'ja-JP') {
      return { greeting: 'こんにちは' }
    }
    return { greeting: 'Hello' }
  },
  run: ctx => {
    console.log(ctx.translate('greeting'))
  }
}

// locale and translationAdapterFactory were in CLI options
await cli(process.argv.slice(2), command, {
  name: 'my-app',
  version: '1.0.0',
  locale: new Intl.Locale('en-US'),
  translationAdapterFactory: customAdapter
})

After (v0.27):

ts
import { cli } from 'gunshi'
import i18n, { defineI18nWithTypes, pluginId as i18nId } from '@gunshi/plugin-i18n'
import type { I18nExtension, PluginId as I18nPluginId } from '@gunshi/plugin-i18n'

// Type-safe command with i18n plugin extension
const command = defineI18nWithTypes<{ extensions: Record<I18nPluginId, I18nExtension> }>()({
  name: 'app',
  // resource function is still used with i18n plugin
  resource: locale => {
    if (locale.toString() === 'ja-JP') {
      return { greeting: 'こんにちは' }
    }
    return { greeting: 'Hello' }
  },
  run: ctx => {
    // Use the translate() function from i18n plugin extension
    console.log(ctx.extensions[i18nId].translate('greeting'))
  }
})

// locale and translationAdapterFactory removed from CLI options
await cli(process.argv.slice(2), command, {
  name: 'my-app',
  version: '1.0.0',
  plugins: [
    i18n({
      locale: 'en-US', // Moved from CLI options
      translationAdapterFactory: customAdapter // Moved from CLI options
    })
  ]
})

Migration steps:

  1. Install the i18n plugin: npm install --save @gunshi/plugin-i18n
  2. Import the plugin and its ID: import i18n, { pluginId as i18nId } from '@gunshi/plugin-i18n'
  3. Move locale from CLI options to i18n plugin options
  4. Move translationAdapterFactory from CLI options to i18n plugin options
  5. Change ctx.translate() to ctx.extensions[i18nId].translate()
  6. Keep using resource function in commands

Additional i18n Resources and Helpers:

The i18n plugin comes with additional packages and helper functions to simplify internationalization:

  • @gunshi/resources: Pre-built localization resources for common CLI terms

    js
    import resources from '@gunshi/resources'
    
    // Use with i18n plugin
    i18n({
      locale: 'en-US',
      builtinResources: resources
    })
  • Helper Functions: Type-safe command definition with i18n

    js
    import { defineI18n, withI18nResource } from '@gunshi/plugin-i18n'
    
    // Define new command with i18n support
    const command = defineI18n({
      name: 'deploy',
      resource: async ctx => ({
        /* translations */
      }),
      run: ctx => {
        /* command logic */
      }
    })
    
    // Add i18n to existing command
    const enhancedCommand = withI18nResource(existingCommand, {
      resource: async ctx => ({
        /* translations */
      })
    })
  • Key Resolution Helpers: Proper namespace management

    js
    import { resolveKey } from '@gunshi/plugin-i18n'
    
    // In subcommands, use `resolveKey` for proper namespacing
    run: ctx => {
      const key = resolveKey('customMessage', ctx.name)
      const message = ctx.extensions[i18nId].translate(key)
    }

📚 Documentation

New Plugin Guides

  • Plugin Ecosystem Guide: Comprehensive introduction to the plugin system
  • Context Extensions Guide: Learn how to leverage plugin extensions
  • Plugin Development Guide: Step-by-step guide for creating custom plugins

Updated API Reference

  • New plugin-related interfaces: PluginOptions, PluginWithExtension, PluginWithoutExtension
  • Enhanced type definitions: GunshiParams, CommandContextExtension
  • Rendering options: RenderingOptions, RendererDecorator

Example Projects

New playground examples demonstrating:

  • Plugin usage patterns
  • Context extension scenarios
  • Advanced type safety with plugins

🧑‍🤝‍🧑 Contributors

We'd like to thank all the contributors who made this release possible:

Special thanks to the community for feedback and bug reports that helped shape v0.27!

💖 Credits

The completion plugin is powered by:

Released under the MIT License.