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.
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--helpand--versionoptions to all commands automatically- Provides
showVersion(),showHeader(),showUsage(), andshowValidationErrors()methods - Intercepts command execution to handle help/version display
- Provides
@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 supportshnpm 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 supportshnpm install @gunshi/plugin-completion- Support for bash, zsh, fish, and PowerShell
- Custom completion handlers for dynamic suggestions
- Automatic
completesubcommand generation - Integration with i18n for localized descriptions
@gunshi/definition - Command Definition Utilities
New standalone package for creating reusable, distributable commands:
npm install @gunshi/definitionThis package enables you to create standalone commands that can be published as libraries and shared across multiple CLI applications:
// 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:
// 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/definitioncan 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:
npm install @gunshi/boneimport { 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:
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:
// 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:
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 linefalse: 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:
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:
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:
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:
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:
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:
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:
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):
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):
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:
- Install the i18n plugin:
npm install --save @gunshi/plugin-i18n - Import the plugin and its ID:
import i18n, { pluginId as i18nId } from '@gunshi/plugin-i18n' - Move
localefrom CLI options to i18n plugin options - Move
translationAdapterFactoryfrom CLI options to i18n plugin options - Change
ctx.translate()toctx.extensions[i18nId].translate() - Keep using
resourcefunction 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 termsjsimport resources from '@gunshi/resources' // Use with i18n plugin i18n({ locale: 'en-US', builtinResources: resources })Helper Functions: Type-safe command definition with i18n
jsimport { 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
jsimport { 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:
- @kazupon - Core maintainer, plugin system architect, i18n extraction, lifecycle hooks
- @yukukotani - Fallback to entry command feature (#291)
- @sushichan044 - Explicit argument detection feature (#232)
- @43081j - Exposed
Plugintype (#159) - @theoephraim - Documentation improvements (#249)
- @lukekarrys - Documentation updates (#228)
- @BobbieGoede - Fixed FUNDING.yml (#303)
Special thanks to the community for feedback and bug reports that helped shape v0.27!
💖 Credits
The completion plugin is powered by:
@bomb.sh/tabby Bombshell - Shell completion library that powers our tab completion functionality
