Repository: yargs/yargs
Pull request #785 (2017-02-18 18:24:38)

introduce support for default commands
@nexdrew's work on introducing default command support.

_Why this is cool:_

1. it allows you to use the same flow control logic for top-level execution of yargs, as is applied to sub-commands.
2. tons of people have asked for it.

_todo:_

- [x] add tests for simple behavior.
- [x] add tests for behavior in conjunction with strict mode.
- [x] add tests for behavior when loading commands from directories.
- [x] document functionality.

bcoe: @sondr3, @m-mujica, @ahmadnassri, @vjpr, would love feedback on this feature -- definitely the feature I'm most excited about for `yargs@7`.

Patch:
diff --git a/README.md b/README.md
index ef2948e..44262f5 100644
--- a/README.md
+++ b/README.md
@@ -591,6 +591,34 @@ yargs
   .argv
 ```
 
+### Default Commands
+
+To specify a default command use the character `*`. A default command
+will be run if the positional arguments provided match no known
+commands:
+
+```js
+const argv = require('yargs')
+  .command('*', 'the default command', () => {}, (argv) => {
+    console.log('this command will be run by default')
+  })
+```
+
+The command defined above will be executed if the program
+is run with `./my-cli.js --x=22`.
+
+Default commands can also be used as a command alias, like so:
+
+```js
+const argv = require('yargs')
+  .command(['serve', '*'], 'the serve command', () => {}, (argv) => {
+    console.log('this command will be run by default')
+  })
+```
+
+The command defined above will be executed if the program
+is run with `./my-cli.js --x=22`, or with `./my-cli.js serve --x=22`.
+
 ### Positional Arguments
 
 Commands can accept _optional_ and _required_ positional arguments. Required
diff --git a/lib/command.js b/lib/command.js
index abc92c2..27fe6d0 100644
--- a/lib/command.js
+++ b/lib/command.js
@@ -2,6 +2,8 @@ const path = require('path')
 const inspect = require('util').inspect
 const camelCase = require('camelcase')
 
+const DEFAULT_MARKER = '*'
+
 // handles parsing positional arguments,
 // and populating argv with said positional
 // arguments.
@@ -10,6 +12,7 @@ module.exports = function (yargs, usage, validation) {
 
   var handlers = {}
   var aliasMap = {}
+  var defaultCommand
   self.addHandler = function (cmd, description, builder, handler) {
     var aliases = []
     if (Array.isArray(cmd)) {
@@ -28,15 +31,50 @@ module.exports = function (yargs, usage, validation) {
       return
     }
 
+    // parse positionals out of cmd string
     var parsedCommand = self.parseCommand(cmd)
+
+    // remove positional args from aliases only
     aliases = aliases.map(function (alias) {
-      alias = self.parseCommand(alias).cmd // remove positional args
+      return self.parseCommand(alias).cmd
+    })
+
+    // check for default and filter out '*''
+    var isDefault = false
+    var parsedAliases = [parsedCommand.cmd].concat(aliases).filter(function (c) {
+      if (c === DEFAULT_MARKER) {
+        isDefault = true
+        return false
+      }
+      return true
+    })
+
+    // short-circuit if default with no aliases
+    if (isDefault && parsedAliases.length === 0) {
+      defaultCommand = {
+        original: cmd.replace(DEFAULT_MARKER, '').trim(),
+        handler: handler,
+        builder: builder || {},
+        demanded: parsedCommand.demanded,
+        optional: parsedCommand.optional
+      }
+      return
+    }
+
+    // shift cmd and aliases after filtering out '*'
+    if (isDefault) {
+      parsedCommand.cmd = parsedAliases[0]
+      aliases = parsedAliases.slice(1)
+      cmd = cmd.replace(DEFAULT_MARKER, parsedCommand.cmd)
+    }
+
+    // populate aliasMap
+    aliases.forEach(function (alias) {
       aliasMap[alias] = parsedCommand.cmd
-      return alias
     })
 
     if (description !== false) {
-      usage.command(cmd, description, aliases)
+      usage.command(cmd, description, isDefault, aliases)
     }
 
     handlers[parsedCommand.cmd] = {
@@ -46,6 +84,8 @@ module.exports = function (yargs, usage, validation) {
       demanded: parsedCommand.demanded,
       optional: parsedCommand.optional
     }
+
+    if (isDefault) defaultCommand = handlers[parsedCommand.cmd]
   }
 
   self.addDirectory = function (dir, context, req, callerFile, opts) {
@@ -130,9 +170,13 @@ module.exports = function (yargs, usage, validation) {
     return handlers
   }
 
+  self.hasDefaultCommand = function () {
+    return !!defaultCommand
+  }
+
   self.runCommand = function (command, yargs, parsed) {
     var aliases = parsed.aliases
-    var commandHandler = handlers[command] || handlers[aliasMap[command]]
+    var commandHandler = handlers[command] || handlers[aliasMap[command]] || defaultCommand
     var currentContext = yargs.getContext()
     var numFiles = currentContext.files.length
     var parentCommands = currentContext.commands.slice()
@@ -142,7 +186,7 @@ module.exports = function (yargs, usage, validation) {
     var innerYargs = null
     var positionalMap = {}
 
-    currentContext.commands.push(command)
+    if (command) currentContext.commands.push(command)
     if (typeof commandHandler.builder === 'function') {
       // a function can be provided, which builds
       // up a yargs chain and possibly returns it.
@@ -186,7 +230,7 @@ module.exports = function (yargs, usage, validation) {
       commandHandler.handler(innerArgv)
     }
 
-    currentContext.commands.pop()
+    if (command) currentContext.commands.pop()
     numFiles = currentContext.files.length - numFiles
     if (numFiles > 0) currentContext.files.splice(numFiles * -1, numFiles)
 
@@ -263,6 +307,7 @@ module.exports = function (yargs, usage, validation) {
   self.reset = function () {
     handlers = {}
     aliasMap = {}
+    defaultCommand = undefined
     return self
   }
 
@@ -275,10 +320,12 @@ module.exports = function (yargs, usage, validation) {
     frozen = {}
     frozen.handlers = handlers
     frozen.aliasMap = aliasMap
+    frozen.defaultCommand = defaultCommand
   }
   self.unfreeze = function () {
     handlers = frozen.handlers
     aliasMap = frozen.aliasMap
+    defaultCommand = frozen.defaultCommand
     frozen = undefined
   }
 
diff --git a/lib/usage.js b/lib/usage.js
index 84ea9a2..43f7122 100644
--- a/lib/usage.js
+++ b/lib/usage.js
@@ -77,8 +77,15 @@ module.exports = function (yargs, y18n) {
   }
 
   var commands = []
-  self.command = function (cmd, description, aliases) {
-    commands.push([cmd, description || '', aliases])
+  self.command = function (cmd, description, isDefault, aliases) {
+    // the last default wins, so cancel out any previously set default
+    if (isDefault) {
+      commands = commands.map(function (cmdArray) {
+        cmdArray[2] = false
+        return cmdArray
+      })
+    }
+    commands.push([cmd, description || '', isDefault, aliases])
   }
   self.getCommands = function () {
     return commands
@@ -166,8 +173,13 @@ module.exports = function (yargs, y18n) {
           {text: command[0], padding: [0, 2, 0, 2], width: maxWidth(commands, theWrap) + 4},
           {text: command[1]}
         )
-        if (command[2] && command[2].length) {
-          ui.div({text: '[' + __('aliases:') + ' ' + command[2].join(', ') + ']', padding: [0, 0, 0, 2], align: 'right'})
+        var hints = []
+        if (command[2]) hints.push('[' + __('default:').slice(0, -1) + ']') // TODO hacking around i18n here
+        if (command[3] && command[3].length) {
+          hints.push('[' + __('aliases:') + ' ' + command[3].join(', ') + ']')
+        }
+        if (hints.length) {
+          ui.div({text: hints.join(' '), padding: [0, 0, 0, 2], align: 'right'})
         } else {
           ui.div()
         }
diff --git a/yargs.js b/yargs.js
index f3cad8d..1843443 100644
--- a/yargs.js
+++ b/yargs.js
@@ -988,6 +988,12 @@ function Yargs (processArgs, cwd, parentRequire) {
             }
           }
 
+          // run the default command, if defined
+          if (command.hasDefaultCommand() && !argv[helpOpt]) {
+            setPlaceholderKeys(argv)
+            return command.runCommand(null, self, parsed)
+          }
+
           // recommend a command if recommendCommands() has
           // been enabled, and no commands were found to execute
           if (recommendCommands && firstUnknownCommand) {
@@ -1001,6 +1007,9 @@ function Yargs (processArgs, cwd, parentRequire) {
           self.showCompletionScript()
           self.exit(0)
         }
+      } else if (command.hasDefaultCommand()) {
+        setPlaceholderKeys(argv)
+        return command.runCommand(null, self, parsed)
       }
 
       // we must run completions first, a user might


Test patch:
diff --git a/test/command.js b/test/command.js
index 058e2a1..dfa5c70 100644
--- a/test/command.js
+++ b/test/command.js
@@ -181,21 +181,23 @@ describe('Command', function () {
     it('accepts string, string as first 2 arguments', function () {
       var cmd = 'foo'
       var desc = 'i\'m not feeling very creative at the moment'
+      var isDefault = false
       var aliases = []
 
       var y = yargs([]).command(cmd, desc)
       var commands = y.getUsageInstance().getCommands()
-      commands[0].should.deep.equal([cmd, desc, aliases])
+      commands[0].should.deep.equal([cmd, desc, isDefault, aliases])
     })
 
     it('accepts array, string as first 2 arguments', function () {
       var aliases = ['bar', 'baz']
       var cmd = 'foo <qux>'
       var desc = 'i\'m not feeling very creative at the moment'
+      var isDefault = false
 
       var y = yargs([]).command([cmd].concat(aliases), desc)
       var usageCommands = y.getUsageInstance().getCommands()
-      usageCommands[0].should.deep.equal([cmd, desc, aliases])
+      usageCommands[0].should.deep.equal([cmd, desc, isDefault, aliases])
       var cmdCommands = y.getCommandInstance().getCommands()
       cmdCommands.should.deep.equal(['foo', 'bar', 'baz'])
     })
@@ -284,6 +286,7 @@ describe('Command', function () {
         builder: function (yargs) { return yargs },
         handler: function (argv) {}
       }
+      var isDefault = false
       var aliases = []
 
       var y = yargs([]).command(module)
@@ -292,7 +295,7 @@ describe('Command', function () {
       handlers.foo.builder.should.equal(module.builder)
       handlers.foo.handler.should.equal(module.handler)
       var commands = y.getUsageInstance().getCommands()
-      commands[0].should.deep.equal([module.command, module.describe, aliases])
+      commands[0].should.deep.equal([module.command, module.describe, isDefault, aliases])
     })
 
     it('accepts module (description key, builder function) as 1st argument', function () {
@@ -302,6 +305,7 @@ describe('Command', function () {
         builder: function (yargs) { return yargs },
         handler: function (argv) {}
       }
+      var isDefault = false
       var aliases = []
 
       var y = yargs([]).command(module)
@@ -310,7 +314,7 @@ describe('Command', function () {
       handlers.foo.builder.should.equal(module.builder)
       handlers.foo.handler.should.equal(module.handler)
       var commands = y.getUsageInstance().getCommands()
-      commands[0].should.deep.equal([module.command, module.description, aliases])
+      commands[0].should.deep.equal([module.command, module.description, isDefault, aliases])
     })
 
     it('accepts module (desc key, builder function) as 1st argument', function () {
@@ -320,6 +324,7 @@ describe('Command', function () {
         builder: function (yargs) { return yargs },
         handler: function (argv) {}
       }
+      var isDefault = false
       var aliases = []
 
       var y = yargs([]).command(module)
@@ -328,7 +333,7 @@ describe('Command', function () {
       handlers.foo.builder.should.equal(module.builder)
       handlers.foo.handler.should.equal(module.handler)
       var commands = y.getUsageInstance().getCommands()
-      commands[0].should.deep.equal([module.command, module.desc, aliases])
+      commands[0].should.deep.equal([module.command, module.desc, isDefault, aliases])
     })
 
     it('accepts module (false describe, builder function) as 1st argument', function () {
@@ -375,6 +380,7 @@ describe('Command', function () {
         },
         handler: function (argv) {}
       }
+      var isDefault = false
       var aliases = []
 
       var y = yargs([]).command(module)
@@ -383,7 +389,7 @@ describe('Command', function () {
       handlers.foo.builder.should.equal(module.builder)
       handlers.foo.handler.should.equal(module.handler)
       var commands = y.getUsageInstance().getCommands()
-      commands[0].should.deep.equal([module.command, module.describe, aliases])
+      commands[0].should.deep.equal([module.command, module.describe, isDefault, aliases])
     })
 
     it('accepts module (missing handler function) as 1st argument', function () {
@@ -396,6 +402,7 @@ describe('Command', function () {
           }
         }
       }
+      var isDefault = false
       var aliases = []
 
       var y = yargs([]).command(module)
@@ -404,7 +411,7 @@ describe('Command', function () {
       handlers.foo.builder.should.equal(module.builder)
       expect(handlers.foo.handler).to.equal(undefined)
       var commands = y.getUsageInstance().getCommands()
-      commands[0].should.deep.equal([module.command, module.describe, aliases])
+      commands[0].should.deep.equal([module.command, module.describe, isDefault, aliases])
     })
 
     it('accepts module (with command array) as 1st argument', function () {
@@ -414,6 +421,7 @@ describe('Command', function () {
         builder: function (yargs) { return yargs },
         handler: function (argv) {}
       }
+      var isDefault = false
 
       var y = yargs([]).command(module)
       var handlers = y.getCommandInstance().getCommandHandlers()
@@ -421,7 +429,7 @@ describe('Command', function () {
       handlers.foo.builder.should.equal(module.builder)
       handlers.foo.handler.should.equal(module.handler)
       var usageCommands = y.getUsageInstance().getCommands()
-      usageCommands[0].should.deep.equal([module.command[0], module.describe, ['bar', 'baz']])
+      usageCommands[0].should.deep.equal([module.command[0], module.describe, isDefault, ['bar', 'baz']])
       var cmdCommands = y.getCommandInstance().getCommands()
       cmdCommands.should.deep.equal(['foo', 'bar', 'baz'])
     })
@@ -434,6 +442,7 @@ describe('Command', function () {
         builder: function (yargs) { return yargs },
         handler: function (argv) {}
       }
+      var isDefault = false
 
       var y = yargs([]).command(module)
       var handlers = y.getCommandInstance().getCommandHandlers()
@@ -441,7 +450,7 @@ describe('Command', function () {
       handlers.foo.builder.should.equal(module.builder)
       handlers.foo.handler.should.equal(module.handler)
       var usageCommands = y.getUsageInstance().getCommands()
-      usageCommands[0].should.deep.equal([module.command, module.describe, module.aliases])
+      usageCommands[0].should.deep.equal([module.command, module.describe, isDefault, module.aliases])
       var cmdCommands = y.getCommandInstance().getCommands()
       cmdCommands.should.deep.equal(['foo', 'bar', 'baz'])
     })
@@ -454,6 +463,7 @@ describe('Command', function () {
         builder: function (yargs) { return yargs },
         handler: function (argv) {}
       }
+      var isDefault = false
 
       var y = yargs([]).command(module)
       var handlers = y.getCommandInstance().getCommandHandlers()
@@ -461,7 +471,7 @@ describe('Command', function () {
       handlers.foo.builder.should.equal(module.builder)
       handlers.foo.handler.should.equal(module.handler)
       var usageCommands = y.getUsageInstance().getCommands()
-      usageCommands[0].should.deep.equal([module.command[0], module.describe, ['bar', 'baz', 'nat']])
+      usageCommands[0].should.deep.equal([module.command[0], module.describe, isDefault, ['bar', 'baz', 'nat']])
       var cmdCommands = y.getCommandInstance().getCommands()
       cmdCommands.should.deep.equal(['foo', 'bar', 'baz', 'nat'])
     })
@@ -474,6 +484,7 @@ describe('Command', function () {
         builder: function (yargs) { return yargs },
         handler: function (argv) {}
       }
+      var isDefault = false
 
       var y = yargs([]).command(module)
       var handlers = y.getCommandInstance().getCommandHandlers()
@@ -481,7 +492,7 @@ describe('Command', function () {
       handlers.foo.builder.should.equal(module.builder)
       handlers.foo.handler.should.equal(module.handler)
       var usageCommands = y.getUsageInstance().getCommands()
-      usageCommands[0].should.deep.equal([module.command, module.describe, ['bar']])
+      usageCommands[0].should.deep.equal([module.command, module.describe, isDefault, ['bar']])
       var cmdCommands = y.getCommandInstance().getCommands()
       cmdCommands.should.deep.equal(['foo', 'bar'])
     })
@@ -1189,4 +1200,90 @@ describe('Command', function () {
       })
     })
   })
+
+  describe('default commands', function () {
+    it('executes default command if no positional arguments given', function (done) {
+      yargs('--foo bar')
+        .command('*', 'default command', function () {}, function (argv) {
+          argv.foo.should.equal('bar')
+          return done()
+        })
+        .argv
+    })
+
+    it('does not execute default command if another command is provided', function (done) {
+      yargs('run bcoe --foo bar')
+        .command('*', 'default command', function () {}, function (argv) {})
+        .command('run <name>', 'run command', function () {}, function (argv) {
+          argv.name.should.equal('bcoe')
+          argv.foo.should.equal('bar')
+          return done()
+        })
+        .argv
+    })
+
+    it('allows default command to be set as alias', function (done) {
+      yargs('bcoe --foo bar')
+        .command(['start <name>', '*'], 'start command', function () {}, function (argv) {
+          argv._.should.eql([])
+          argv.name.should.equal('bcoe')
+          argv.foo.should.equal('bar')
+          return done()
+        })
+        .argv
+    })
+
+    it('allows command to be run when alias is default command', function (done) {
+      yargs('start bcoe --foo bar')
+        .command(['start <name>', '*'], 'start command', function () {}, function (argv) {
+          argv._.should.eql(['start'])
+          argv.name.should.equal('bcoe')
+          argv.foo.should.equal('bar')
+          return done()
+        })
+        .argv
+    })
+
+    it('the last default command set should take precedence', function (done) {
+      yargs('bcoe --foo bar')
+        .command(['first', '*'], 'override me', function () {}, function () {})
+        .command(['second <name>', '*'], 'start command', function () {}, function (argv) {
+          argv._.should.eql([])
+          argv.name.should.equal('bcoe')
+          argv.foo.should.equal('bar')
+          return done()
+        })
+        .argv
+    })
+
+    describe('strict', function () {
+      it('executes default command when strict mode is enabled', function (done) {
+        yargs('--foo bar')
+          .command('*', 'default command', function () {}, function (argv) {
+            argv.foo.should.equal('bar')
+            return done()
+          })
+          .option('foo', {
+            describe: 'a foo command'
+          })
+          .strict()
+          .argv
+      })
+
+      it('allows default command aliases, when strict mode is enabled', function (done) {
+        yargs('bcoe --foo bar')
+          .command(['start <name>', '*'], 'start command', function () {}, function (argv) {
+            argv._.should.eql([])
+            argv.name.should.equal('bcoe')
+            argv.foo.should.equal('bar')
+            return done()
+          })
+          .strict()
+          .option('foo', {
+            describe: 'a foo command'
+          })
+          .argv
+      })
+    })
+  })
 })