Repository: yargs/yargs
Pull request #574 (2016-08-03 01:04:05)

feat: .help() API can now enable implicit help command
In addition to a flagged option (e.g. `--help`), `.help()` now also supports an implicit command (e.g. `help)`, which is enabled by default.

This means any yargs CLI can now enjoy commands such as:

```console
$ cli help
$ cli help command
$ cli command help
```

No flag needed. 😎 

I opted to build the implicit command into the existing `.help()` API (instead of creating a new method) in order to:

1. reuse the same tokens for the flag and the command
2. enable it by default for most uses of `.help()`

It can be explicitly disabled by passing `false` as one of the 3 arguments.

Note that any help option aliases will also be used as an alias for the command, as long as:

1. the alias has a length greater than 1, or
2. all aliases have a length of 1

This should be considered a 5.x breaking change in case existing CLIs use their own `help` command.


Patch:
diff --git a/README.md b/README.md
index 8835fae..8dfdf5a 100644
--- a/README.md
+++ b/README.md
@@ -1099,15 +1099,31 @@ var yargs = require('yargs')(['--help'])
     Options:
       --help  Show help  [boolean]
 
-<a name="help"></a>.help([option, [description]])
-------------------------------
+<a name="help"></a>.help()
+-----------------------------------------
+.help([option | boolean])
+-----------------------------------------
+.help([option, [description | boolean]])
+-----------------------------------------
+.help([option, [description, [boolean]]])
+-----------------------------------------
+
+Add an option (e.g. `--help`) and implicit command that displays the usage
+string and exits the process.
 
-Add an option (e.g. `--help`) that displays the usage string and exits the
-process. If present, the `description` parameter customizes the description of
+If present, the `description` parameter customizes the description of
 the help option in the usage string.
 
-If invoked without parameters, `.help()` will make `--help` the option to trigger
-help output.
+If a boolean argument is provided, it will enable or disable the use of an
+implicit command. The implicit command is enabled by default, but it can be
+disabled by passing `false`.
+
+Note that any multi-char aliases (e.g. `help`) used for the help option will
+also be used for the implicit command. If there are no multi-char aliases (e.g.
+`h`), then all single-char aliases will be used for the command.
+
+If invoked without parameters, `.help()` will use `--help` as the option and
+`help` as the implicit command to trigger help output.
 
 Example:
 
diff --git a/package.json b/package.json
index ab87282..a176645 100644
--- a/package.json
+++ b/package.json
@@ -35,7 +35,7 @@
     "cross-spawn": "^4.0.0",
     "es6-promise": "^3.0.2",
     "hashish": "0.0.4",
-    "mocha": "^3.0.0",
+    "mocha": "^3.0.1",
     "nyc": "^7.0.0",
     "rimraf": "^2.5.0",
     "standard": "^7.0.0",
diff --git a/yargs.js b/yargs.js
index ed6fd56..3377b1d 100644
--- a/yargs.js
+++ b/yargs.js
@@ -520,12 +520,33 @@ function Yargs (processArgs, cwd, parentRequire) {
   }
 
   var helpOpt = null
-  self.addHelpOpt = self.help = function (opt, msg) {
-    opt = opt || 'help'
-    helpOpt = opt
-    self.boolean(opt)
-    self.global(opt)
-    self.describe(opt, msg || usage.deferY18nLookup('Show help'))
+  var useHelpOptAsCommand = false // a call to .help() will enable this
+  self.addHelpOpt = self.help = function (opt, msg, addImplicitCmd) {
+    // argument shuffle
+    if (arguments.length === 0) {
+      useHelpOptAsCommand = true
+    } else if (arguments.length === 1) {
+      if (typeof opt === 'boolean') {
+        useHelpOptAsCommand = opt
+        opt = null
+      } else {
+        useHelpOptAsCommand = true
+      }
+    } else if (arguments.length === 2) {
+      if (typeof msg === 'boolean') {
+        useHelpOptAsCommand = msg
+        msg = null
+      } else {
+        useHelpOptAsCommand = true
+      }
+    } else {
+      useHelpOptAsCommand = Boolean(addImplicitCmd)
+    }
+    // use arguments, fallback to defaults for opt and msg
+    helpOpt = opt || 'help'
+    self.boolean(helpOpt)
+    self.global(helpOpt)
+    self.describe(helpOpt, msg || usage.deferY18nLookup('Show help'))
     return self
   }
 
@@ -654,22 +675,45 @@ function Yargs (processArgs, cwd, parentRequire) {
       return argv
     }
 
-    // if there's a handler associated with a
-    // command defer processing to it.
-    var handlerKeys = command.getCommands()
-    for (var i = 0, cmd; (cmd = argv._[i]) !== undefined; i++) {
-      if (~handlerKeys.indexOf(cmd) && cmd !== completionCommand) {
-        setPlaceholderKeys(argv)
-        return command.runCommand(cmd, self, parsed)
+    if (argv._.length) {
+      // check for helpOpt in argv._ before running commands
+      // assumes helpOpt must be valid if useHelpOptAsCommand is true
+      if (useHelpOptAsCommand) {
+        // consider any multi-char helpOpt alias as a valid help command
+        // unless all helpOpt aliases are single-char
+        // note that parsed.aliases is a normalized bidirectional map :)
+        var helpCmds = [helpOpt].concat(aliases[helpOpt])
+        var multiCharHelpCmds = helpCmds.filter(function (k) {
+          return k.length > 1
+        })
+        if (multiCharHelpCmds.length) helpCmds = multiCharHelpCmds
+        // look for and strip any helpCmds from argv._
+        argv._ = argv._.filter(function (cmd) {
+          if (~helpCmds.indexOf(cmd)) {
+            argv[helpOpt] = true
+            return false
+          }
+          return true
+        })
+      }
+
+      // if there's a handler associated with a
+      // command defer processing to it.
+      var handlerKeys = command.getCommands()
+      for (var i = 0, cmd; (cmd = argv._[i]) !== undefined; i++) {
+        if (~handlerKeys.indexOf(cmd) && cmd !== completionCommand) {
+          setPlaceholderKeys(argv)
+          return command.runCommand(cmd, self, parsed)
+        }
       }
-    }
 
-    // generate a completion script for adding to ~/.bashrc.
-    if (completionCommand && ~argv._.indexOf(completionCommand) && !argv[completion.completionKey]) {
-      if (exitProcess) setBlocking(true)
-      self.showCompletionScript()
-      if (exitProcess) {
-        process.exit(0)
+      // generate a completion script for adding to ~/.bashrc.
+      if (completionCommand && ~argv._.indexOf(completionCommand) && !argv[completion.completionKey]) {
+        if (exitProcess) setBlocking(true)
+        self.showCompletionScript()
+        if (exitProcess) {
+          process.exit(0)
+        }
       }
     }
 


Test patch:
diff --git a/test/command.js b/test/command.js
index 96ad4c4..ae126c2 100644
--- a/test/command.js
+++ b/test/command.js
@@ -470,6 +470,83 @@ describe('Command', function () {
     })
   })
 
+  describe('help command', function () {
+    it('displays command help appropriately', function () {
+      var sub = {
+        command: 'sub',
+        desc: 'Run the subcommand',
+        builder: {},
+        handler: function (argv) {}
+      }
+
+      var cmd = {
+        command: 'cmd <sub>',
+        desc: 'Try a command',
+        builder: function (yargs) {
+          return yargs.command(sub)
+        },
+        handler: function (argv) {}
+      }
+
+      var helpCmd = checkOutput(function () {
+        return yargs('help cmd')
+          .help().wrap(null)
+          .command(cmd)
+          .argv
+      }, [ './command' ])
+
+      var cmdHelp = checkOutput(function () {
+        return yargs('cmd help')
+          .help().wrap(null)
+          .command(cmd)
+          .argv
+      }, [ './command' ])
+
+      var helpCmdSub = checkOutput(function () {
+        return yargs('help cmd sub')
+          .help().wrap(null)
+          .command(cmd)
+          .argv
+      }, [ './command' ])
+
+      var cmdHelpSub = checkOutput(function () {
+        return yargs('cmd help sub')
+          .help().wrap(null)
+          .command(cmd)
+          .argv
+      }, [ './command' ])
+
+      var cmdSubHelp = checkOutput(function () {
+        return yargs('cmd sub help')
+          .help().wrap(null)
+          .command(cmd)
+          .argv
+      }, [ './command' ])
+
+      var expectedCmd = [
+        './command cmd <sub>',
+        'Commands:',
+        '  sub  Run the subcommand',
+        'Options:',
+        '  --help  Show help  [boolean]',
+        ''
+      ]
+
+      var expectedSub = [
+        './command cmd sub',
+        'Options:',
+        '  --help  Show help  [boolean]',
+        ''
+      ]
+
+      helpCmd.logs.join('\n').split(/\n+/).should.deep.equal(expectedCmd)
+      cmdHelp.logs.join('\n').split(/\n+/).should.deep.equal(expectedCmd)
+      helpCmdSub.logs.join('\n').split(/\n+/).should.deep.equal(expectedSub)
+      cmdHelpSub.logs.join('\n').split(/\n+/).should.deep.equal(expectedSub)
+      cmdSubHelp.logs.join('\n').split(/\n+/).should.deep.equal(expectedSub)
+    })
+  })
+
   // addresses https://github.com/yargs/yargs/issues/514.
   it('respects order of positional arguments when matching commands', function () {
     var output = []
diff --git a/test/yargs.js b/test/yargs.js
index aacf7b8..32c6a91 100644
--- a/test/yargs.js
+++ b/test/yargs.js
@@ -686,6 +686,11 @@ describe('yargs dsl tests', function () {
       a1.why.should.equal('hello world')
       a2.why.should.equal('hello world')
     })
+
+    it('ignores implicit help command (with short-circuit)', function () {
+      var parsed = yargs.help().parse('help', true)
+      parsed._.should.deep.equal(['help'])
+    })
   })
 
   describe('config', function () {
@@ -1008,6 +1013,262 @@ describe('yargs dsl tests', function () {
       argv.koala.should.equal(true)
     })
   })
+
+  describe('.help()', function () {
+    it('enables `--help` option and `help` command without arguments', function () {
+      var option = checkOutput(function () {
+        return yargs('--help')
+          .help()
+          .wrap(null)
+          .argv
+      })
+      var command = checkOutput(function () {
+        return yargs('help')
+          .help()
+          .wrap(null)
+          .argv
+      })
+      var expected = [
+        'Options:',
+        '  --help  Show help  [boolean]',
+        ''
+      ]
+      option.logs[0].split('\n').should.deep.equal(expected)
+      command.logs[0].split('\n').should.deep.equal(expected)
+    })
+
+    it('enables `--help` option and `help` command with `true` argument', function () {
+      var option = checkOutput(function () {
+        return yargs('--help')
+          .help(true)
+          .wrap(null)
+          .argv
+      })
+      var command = checkOutput(function () {
+        return yargs('help')
+          .help(true)
+          .wrap(null)
+          .argv
+      })
+      var expected = [
+        'Options:',
+        '  --help  Show help  [boolean]',
+        ''
+      ]
+      option.logs[0].split('\n').should.deep.equal(expected)
+      command.logs[0].split('\n').should.deep.equal(expected)
+    })
+
+    it('enables only `--help` option with `false` argument', function () {
+      var option = checkOutput(function () {
+        return yargs('--help')
+          .help(false)
+          .wrap(null)
+          .argv
+      })
+      var command = checkOutput(function () {
+        return yargs('help')
+          .help(false)
+          .wrap(null)
+          .argv
+      })
+      option.logs[0].split('\n').should.deep.equal([
+        'Options:',
+        '  --help  Show help  [boolean]',
+        ''
+      ])
+      command.result.should.have.property('_').and.deep.equal(['help'])
+    })
+
+    it('enables given string as help option and command with string argument', function () {
+      var option = checkOutput(function () {
+        return yargs('--info')
+          .help('info')
+          .wrap(null)
+          .argv
+      })
+      var command = checkOutput(function () {
+        return yargs('info')
+          .help('info')
+          .wrap(null)
+          .argv
+      })
+      var helpOption = checkOutput(function () {
+        return yargs('--help')
+          .help('info')
+          .wrap(null)
+          .argv
+      })
+      var expected = [
+        'Options:',
+        '  --info  Show help  [boolean]',
+        ''
+      ]
+      option.logs[0].split('\n').should.deep.equal(expected)
+      command.logs[0].split('\n').should.deep.equal(expected)
+      helpOption.result.should.have.property('help').and.be.true
+    })
+
+    it('enables given string as help option and command with string argument and `true` argument', function () {
+      var option = checkOutput(function () {
+        return yargs('--info')
+          .help('info', true)
+          .wrap(null)
+          .argv
+      })
+      var command = checkOutput(function () {
+        return yargs('info')
+          .help('info', true)
+          .wrap(null)
+          .argv
+      })
+      var helpOption = checkOutput(function () {
+        return yargs('--help')
+          .help('info', true)
+          .wrap(null)
+          .argv
+      })
+      var expected = [
+        'Options:',
+        '  --info  Show help  [boolean]',
+        ''
+      ]
+      option.logs[0].split('\n').should.deep.equal(expected)
+      command.logs[0].split('\n').should.deep.equal(expected)
+      helpOption.result.should.have.property('help').and.be.true
+    })
+
+    it('enables given string as help option only with string argument and `false` argument', function () {
+      var option = checkOutput(function () {
+        return yargs('--info')
+          .help('info', false)
+          .wrap(null)
+          .argv
+      })
+      var command = checkOutput(function () {
+        return yargs('info')
+          .help('info', false)
+          .wrap(null)
+          .argv
+      })
+      option.logs[0].split('\n').should.deep.equal([
+        'Options:',
+        '  --info  Show help  [boolean]',
+        ''
+      ])
+      command.result.should.have.property('_').and.deep.equal(['info'])
+    })
+
+    it('enables given string as help option and command with custom description with two string arguments', function () {
+      var option = checkOutput(function () {
+        return yargs('--info')
+          .help('info', 'Display info')
+          .wrap(null)
+          .argv
+      })
+      var command = checkOutput(function () {
+        return yargs('info')
+          .help('info', 'Display info')
+          .wrap(null)
+          .argv
+      })
+      var expected = [
+        'Options:',
+        '  --info  Display info  [boolean]',
+        ''
+      ]
+      option.logs[0].split('\n').should.deep.equal(expected)
+      command.logs[0].split('\n').should.deep.equal(expected)
+    })
+
+    it('enables given string as help option and command with custom description with two string arguments and `true` argument', function () {
+      var option = checkOutput(function () {
+        return yargs('--info')
+          .help('info', 'Display info', true)
+          .wrap(null)
+          .argv
+      })
+      var command = checkOutput(function () {
+        return yargs('info')
+          .help('info', 'Display info', true)
+          .wrap(null)
+          .argv
+      })
+      var expected = [
+        'Options:',
+        '  --info  Display info  [boolean]',
+        ''
+      ]
+      option.logs[0].split('\n').should.deep.equal(expected)
+      command.logs[0].split('\n').should.deep.equal(expected)
+    })
+
+    it('enables given string as help option only and custom description with two string arguments and `false` argument', function () {
+      var option = checkOutput(function () {
+        return yargs('--info')
+          .help('info', 'Display info', false)
+          .wrap(null)
+          .argv
+      })
+      var command = checkOutput(function () {
+        return yargs('info')
+          .help('info', 'Display info', false)
+          .wrap(null)
+          .argv
+      })
+      option.logs[0].split('\n').should.deep.equal([
+        'Options:',
+        '  --info  Display info  [boolean]',
+        ''
+      ])
+      command.result.should.have.property('_').and.deep.equal(['info'])
+    })
+  })
+
+  describe('.help() with .alias()', function () {
+    it('uses multi-char (but not single-char) help alias as command', function () {
+      var info = checkOutput(function () {
+        return yargs('info')
+          .help().alias('h', 'help').alias('h', 'info')
+          .wrap(null)
+          .argv
+      })
+      var h = checkOutput(function () {
+        return yargs('h')
+          .help().alias('h', 'help').alias('h', 'info')
+          .wrap(null)
+          .argv
+      })
+      info.logs[0].split('\n').should.deep.equal([
+        'Options:',
+        '  -h, --help, --info  Show help  [boolean]',
+        ''
+      ])
+      h.result.should.have.property('_').and.deep.equal(['h'])
+    })
+
+    it('uses single-char help alias as command if there are no multi-char aliases', function () {
+      var h = checkOutput(function () {
+        return yargs('h')
+          .help('h').alias('h', '?')
+          .wrap(null)
+          .argv
+      })
+      var q = checkOutput(function () {
+        return yargs('?')
+          .help('h').alias('h', '?')
+          .wrap(null)
+          .argv
+      })
+      var expected = [
+        'Options:',
+        '  -h, -?  Show help  [boolean]',
+        ''
+      ]
+      h.logs[0].split('\n').should.deep.equal(expected)
+      q.logs[0].split('\n').should.deep.equal(expected)
+    })
+  })
 })
 
 describe('yargs context', function () {