Repository: yargs/yargs
Pull request #494 (2016-05-03 15:44:24)

Add .commandDir(dir) to API to apply all command modules from a relative directory
This replaces #463, opting to require modules from a single directory at a time via `require-directory` (instead of supporting globs).

The implementation is slightly trickier than I originally thought, but it allows a consumer to do things like the following.

### Example setup

**directory structure**:

```
cli.js
commands/
  people.js
  places.js
  things.js
  common_cmds/
    details.js
    search.js
  people_cmds/
    call.js
  places_cmds/
    nav.js
```

**cli.js**:

```js
#!/usr/bin/env node
require('yargs')
  // apply people.js, places.js, things.js but not subdirectories
  .commandDir('commands')
  .help()
  .wrap(40)
  .argv
```

**commands/people.js**:

```js
exports.command = 'people <command>'
exports.desc = 'Work with people'
exports.builder = function (yargs) {
  // apply commands in subdirectories
  return yargs.commandDir('common_cmds').commandDir('people_cmds')
}
exports.handler = function (argv) {}
```

**commands/places.js**:

```js
exports.command = 'places <command>'
exports.desc = 'Work with places'
exports.builder = function (yargs) {
  // you can even share the same subcommands e.g. common_cmds
  return yargs.commandDir('common_cmds').commandDir('places_cmds')
}
exports.handler = function (argv) {}
```

**commands/things.js**:

```js
exports.command = 'things <command>'
exports.desc = 'Work with things'
exports.builder = function (yargs) {
  return yargs.commandDir('common_cmds')
}
exports.handler = function (argv) {}
```

**commands/common_cmds/details.js**:

```js
exports.command = 'details <name>'
exports.desc = 'Get details by name'
exports.builder = {}
exports.handler = function (argv) {
  // parent command accessible via argv._ as always
  console.log('getting %s details for name: %s', argv._[0], argv.name)
}
```

### Example CLI

```
$ ./cli.js --help
Commands:
  people <command>  Work with people
  places <command>  Work with places
  things <command>  Work with things

Options:
  --help  Show help            [boolean]
```

```
$ ./cli.js people --help
cli.js people <command>

Commands:
  details <name>  Get details by name
  search <query>  Search via query
  call <person>   Call someone

Options:
  --help  Show help            [boolean]
```

```
$ ./cli.js people details       
cli.js people details <name>

Options:
  --help  Show help            [boolean]

Not enough non-option arguments: got 0, need at least 1
```

```
$ ./cli.js people details Andrew
getting people details for name: Andrew
```

### API

This adds one method to the API: `.commandDir(dir, [opts])`

`dir` is a directory name which can/should be relative to the module calling the method.

`opts` is an options object that is passed to `require-directory`, allowing consumers to customize the behavior. In the initial implementation, the following opts are valid:

- `opts.recurse`: boolean, default `false`

    Should yargs require command modules from all nested directories? The default is `false` so subcommands can be nested instead of flattened.

- `opts.extensions`: array of strings, default `['js']`

    The types of files to include when requiring.

- `opts.visit`: function

    A synchronous function that is called for each command module encountered. Accepts `commandObject`, `pathToFile`, and `filename` as arguments. Returns `commandObject` to include the command; any falsey value to exclude/skip it.

- `opts.include`: RegExp or function

    Whitelist certain modules. See [require-directory whitelisting](https://www.npmjs.com/package/require-directory#whitelisting) for details.

- `opts.exclude`: RegExp or function

    Blacklist certain modules. See [require-directory blacklisting](https://www.npmjs.com/package/require-directory#blacklisting) for details.

### Notes

This also includes a change that allows a command module to only define `command` and `description`, which better supports requiring JSON modules (though I'm not sure why someone would want to do that) and fulfills (at least) a part of #492.

I considered adding support for using the filename of a command module as its `command` string if the module itself doesn't define `command`, but I think it'd be nice to support this for _all_ command modules (i.e. not just when using `.commandDir()`), so I'd like to tackle that separately/subsequently.

I don't have strong feelings about the method name `commandDir`. Would be happy to change it if folks like something else better. As always, feedback is welcomed!

### Still lacking

- [x] Tests!
- [x] README update

coveralls: 
[![Coverage Status](https://coveralls.io/builds/6030872/badge)](https://coveralls.io/builds/6030872)

Coverage decreased (-1.9%) to 98.089% when pulling **8b2374d321b84837fc5a63b838e261364a8be0ab on command-dir** into **82d4412cfabf242526de54f37fbc98583545884e on master**.

bcoe: @nexdrew I love this work, don't suppose we could get a test in the mix :)
AGresvig: Yes, this is exactly what I had hoped for - I think the chosen approach makes perfect sense and complements the functionality provided by command modules really well. Love it.
Please let this find it's way into master in the near future.

Great work @nexdrew 
nexdrew: @AGresvig Thanks! I'll get some tests and docs written up within the next couple days so we can merge this safely.
coveralls: 
[![Coverage Status](https://coveralls.io/builds/6298251/badge)](https://coveralls.io/builds/6298251)

Coverage remained the same at 100.0% when pulling **1d566c8a2b1c41892f08d3eef2d87c6b885095cf on command-dir** into **a54c7429fecac5c64a0ede4f80f85dcb5c67c661 on master**.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/6317644/badge)](https://coveralls.io/builds/6317644)

Coverage remained the same at 100.0% when pulling **2b7d6910892bdb1810ba7ad8187015a4cd3a0cf6 on command-dir** into **a54c7429fecac5c64a0ede4f80f85dcb5c67c661 on master**.

nexdrew: @bcoe @maxrimue I believe this is ready for final review.
maxrimue: Looks awesome, well done! Could we perhaps make some variables constants since there are some good reasons for doing so in yargs in general as shown in #502?
nexdrew: > Could we perhaps make some variables constants since there are some good reasons for doing so in yargs in general as shown in #502?

@maxrimue Ooh, good catch! I think there are probably a couple places I could use `const` in here. I'll review that and push whatever changes are compatible with tests.
nexdrew: @maxrimue Swapped `var` for `const` in commit 0d14096.
maxrimue: @nexdrew Awesome! LGTM
coveralls: 
[![Coverage Status](https://coveralls.io/builds/6368363/badge)](https://coveralls.io/builds/6368363)

Coverage remained the same at 100.0% when pulling **0d1409674b87a9b3a82e79428830973625f5b3a7 on command-dir** into **5dd1e636286c62a87563744ce0761a2a09a317ce on master**.


Patch:
diff --git a/README.md b/README.md
index c6cc0e8..652e012 100644
--- a/README.md
+++ b/README.md
@@ -587,8 +587,138 @@ yargs.command('get <source> [proxy]', 'make a get HTTP request', require('my-mod
   .argv
 ```
 
-.completion([cmd], [description], [fn]);
-----------------------------------------
+.commandDir(directory, [opts])
+------------------------------
+
+Apply command modules from a directory relative to the module calling this method.
+
+This allows you to organize multiple commands into their own modules under a
+single directory and apply all of them at once instead of calling
+`.command(require('./dir/module'))` multiple times.
+
+By default, it ignores subdirectories. This is so you can use a directory
+structure to represent your command hierarchy, where each command applies its
+subcommands using this method in its builder function. See the example below.
+
+Note that yargs assumes all modules in the given directory are command modules
+and will error if non-command modules are encountered. In this scenario, you
+can either move your module to a different directory or use the `exclude` or
+`visit` option to manually filter it out. More on that below.
+
+`directory` is a relative directory path as a string (required).
+
+`opts` is an options object (optional). The following options are valid:
+
+- `recurse`: boolean, default `false`
+
+    Look for command modules in all subdirectories and apply them as a flattened
+    (non-hierarchical) list.
+
+- `extensions`: array of strings, default `['js']`
+
+    The types of files to look for when requiring command modules.
+
+- `visit`: function
+
+    A synchronous function called for each command module encountered. Accepts
+    `commandObject`, `pathToFile`, and `filename` as arguments. Returns
+    `commandObject` to include the command; any falsy value to exclude/skip it.
+
+- `include`: RegExp or function
+
+    Whitelist certain modules. See [`require-directory` whitelisting](https://www.npmjs.com/package/require-directory#whitelisting) for details.
+
+- `exclude`: RegExp or function
+
+    Blacklist certain modules. See [`require-directory` blacklisting](https://www.npmjs.com/package/require-directory#blacklisting) for details.
+
+### Example command hierarchy using `.commandDir()`
+
+Desired CLI:
+
+```sh
+$ myapp --help
+$ myapp init
+$ myapp remote --help
+$ myapp remote add base http://yargs.js.org
+$ myapp remote prune base
+$ myapp remote prune base fork whatever
+```
+
+Directory structure:
+
+```
+myapp/
+├─ cli.js
+└─ cmds/
+   ├─ init.js
+   ├─ remote.js
+   └─ remote_cmds/
+      ├─ add.js
+      └─ prune.js
+```
+
+cli.js:
+
+```js
+#!/usr/bin/env node
+require('yargs')
+  .commandDir('cmds')
+  .demand(1)
+  .help()
+  .argv
+```
+
+cmds/init.js:
+
+```js
+exports.command = 'init [dir]'
+exports.desc = 'Create an empty repo'
+exports.builder = {
+  dir: {
+    default: '.'
+  }
+}
+exports.handler = function (argv) {
+  console.log('init called for dir', argv.dir)
+}
+```
+
+cmds/remote.js:
+
+```js
+exports.command = 'remote <command>'
+exports.desc = 'Manage set of tracked repos'
+exports.builder = function (yargs) {
+  return yargs.commandDir('remote_cmds')
+}
+exports.handler = function (argv) {}
+```
+
+cmds/remote_cmds/add.js:
+
+```js
+exports.command = 'add <name> <url>'
+exports.desc = 'Add remote named <name> for repo at url <url>'
+exports.builder = {}
+exports.handler = function (argv) {
+  console.log('adding remote %s at url %s', argv.name, argv.url)
+}
+```
+
+cmds/remote_cmds/prune.js:
+
+```js
+exports.command = 'prune <name> [names..]'
+exports.desc = 'Delete tracked branches gone stale for remotes'
+exports.builder = {}
+exports.handler = function (argv) {
+  console.log('pruning remotes %s', [].concat(argv.name).concat(argv.names).join(', '))
+}
+```
+
+.completion([cmd], [description], [fn])
+---------------------------------------
 
 Enable bash-completion shortcuts for commands and options.
 
diff --git a/lib/command.js b/lib/command.js
index a94722e..77555e9 100644
--- a/lib/command.js
+++ b/lib/command.js
@@ -1,3 +1,6 @@
+const path = require('path')
+const requireDirectory = require('require-directory')
+
 // handles parsing positional arguments,
 // and populating argv with said positional
 // arguments.
@@ -6,9 +9,13 @@ module.exports = function (yargs, usage, validation) {
 
   var handlers = {}
   self.addHandler = function (cmd, description, builder, handler) {
-    // allow a module to define all properties
-    if (typeof cmd === 'object' && typeof cmd.command === 'string' && cmd.builder && typeof cmd.handler === 'function') {
-      self.addHandler(cmd.command, extractDesc(cmd), cmd.builder, cmd.handler)
+    // allow modules that define (a) all properties or (b) only command and description
+    if (typeof cmd === 'object' && typeof cmd.command === 'string') {
+      if (cmd.builder && typeof cmd.handler === 'function') {
+        self.addHandler(cmd.command, extractDesc(cmd), cmd.builder, cmd.handler)
+      } else {
+        self.addHandler(cmd.command, extractDesc(cmd))
+      }
       return
     }
 
@@ -35,6 +42,36 @@ module.exports = function (yargs, usage, validation) {
     }
   }
 
+  self.addDirectory = function (dir, context, req, mainFilename, opts) {
+    opts = opts || {}
+    // dir should be relative to the command module
+    dir = path.join(context.dirs[context.commands.join('|')] || '', dir)
+    // disable recursion to support nested directories of subcommands
+    if (typeof opts.recurse !== 'boolean') opts.recurse = false
+    // exclude 'json', 'coffee' from require-directory defaults
+    if (!Array.isArray(opts.extensions)) opts.extensions = ['js']
+    // allow consumer to define their own visitor function
+    const parentVisit = typeof opts.visit === 'function' ? opts.visit : function (o) { return o }
+    // call addHandler via visitor function
+    opts.visit = function (obj, joined, filename) {
+      const visited = parentVisit(obj, joined, filename)
+      // allow consumer to skip modules with their own visitor
+      if (visited) {
+        // check for cyclic reference
+        // each command file path should only be seen once per execution
+        if (~context.files.indexOf(joined)) return visited
+        // keep track of visited files in context.files
+        context.files.push(joined)
+        // map "command path" to the directory path it came from
+        // so that dir can be relative in the API
+        context.dirs[context.commands.concat(parseCommand(visited.command).cmd).join('|')] = dir
+        self.addHandler(visited)
+      }
+      return visited
+    }
+    requireDirectory({ require: req, filename: mainFilename }, dir, opts)
+  }
+
   function extractDesc (obj) {
     for (var keys = ['describe', 'description', 'desc'], i = 0, l = keys.length, test; i < l; i++) {
       test = obj[keys[i]]
diff --git a/package.json b/package.json
index 0ce26fb..f3a8bef 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
     "os-locale": "^1.4.0",
     "pkg-conf": "^1.1.2",
     "read-pkg-up": "^1.0.1",
+    "require-directory": "^2.1.1",
     "require-main-filename": "^1.0.1",
     "set-blocking": "^2.0.0",
     "string-width": "^1.0.1",
diff --git a/yargs.js b/yargs.js
index e1a51a1..5b4807f 100644
--- a/yargs.js
+++ b/yargs.js
@@ -50,7 +50,7 @@ function Yargs (processArgs, cwd, parentRequire) {
 
   // use context object to keep track of resets, subcommand execution, etc
   // submodules should modify and check the state of context as necessary
-  const context = { resets: -1, commands: [] }
+  const context = { resets: -1, commands: [], dirs: {}, files: [] }
   self.getContext = function () {
     return context
   }
@@ -206,6 +206,12 @@ function Yargs (processArgs, cwd, parentRequire) {
     return self
   }
 
+  self.commandDir = function (dir, opts) {
+    const req = parentRequire || require
+    command.addDirectory(dir, self.getContext(), req, requireMainFilename(req), opts)
+    return self
+  }
+
   self.string = function (strings) {
     options.string.push.apply(options.string, [].concat(strings))
     return self


Test patch:
diff --git a/test/command.js b/test/command.js
index 5f5b696..34e2a9f 100644
--- a/test/command.js
+++ b/test/command.js
@@ -1,6 +1,7 @@
 /* global describe, it, beforeEach */
 var yargs = require('../')
 var expect = require('chai').expect
+var checkOutput = require('./helpers/utils').checkOutput
 
 require('chai').should()
 
@@ -318,4 +319,121 @@ describe('Command', function () {
       commands[0].should.deep.equal([module.command, module.describe])
     })
   })
+
+  describe('commandDir', function () {
+    it('supports relative dirs', function () {
+      var r = checkOutput(function () {
+        return yargs('--help').help().wrap(null)
+          // assumes cwd is node_modules/mocha/bin
+          .commandDir('../../../test/fixtures/cmddir')
+          .argv
+      })
+      r.should.have.property('exit').and.be.true
+      r.should.have.property('errors').with.length(0)
+      r.should.have.property('logs')
+      r.logs.join('\n').split(/\n+/).should.deep.equal([
+        'Commands:',
+        '  dream [command] [opts]  Go to sleep and dream',
+        'Options:',
+        '  --help  Show help  [boolean]',
+        ''
+      ])
+    })
+
+    it('supports nested subcommands', function () {
+      var r = checkOutput(function () {
+        return yargs('dream --help').help().wrap(null)
+          // assumes cwd is node_modules/mocha/bin
+          .commandDir('../../../test/fixtures/cmddir')
+          .argv
+      }, [ './command' ])
+      r.should.have.property('exit').and.be.true
+      r.should.have.property('errors').with.length(0)
+      r.should.have.property('logs')
+      r.logs[0].split(/\n+/).should.deep.equal([
+        './command dream [command] [opts]',
+        'Commands:',
+        '  of-memory <memory>               Dream about a specific memory',
+        '  within-a-dream [command] [opts]  Dream within a dream',
+        'Options:',
+        '  --help     Show help  [boolean]',
+        '  --shared   Is the dream shared with others?  [boolean]',
+        '  --extract  Attempt extraction?  [boolean]',
+        ''
+      ])
+    })
+
+    it('supports a "recurse" boolean option', function () {
+      var r = checkOutput(function () {
+        return yargs('--help').help().wrap(null)
+          // assumes cwd is node_modules/mocha/bin
+          .commandDir('../../../test/fixtures/cmddir', { recurse: true })
+          .argv
+      })
+      r.should.have.property('exit').and.be.true
+      r.should.have.property('errors').with.length(0)
+      r.should.have.property('logs')
+      r.logs.join('\n').split(/\n+/).should.deep.equal([
+        'Commands:',
+        '  limbo [opts]                     Get lost in pure subconscious',
+        '  inception [command] [opts]       Enter another dream, where inception is possible',
+        '  within-a-dream [command] [opts]  Dream within a dream',
+        '  dream [command] [opts]           Go to sleep and dream',
+        'Options:',
+        '  --help  Show help  [boolean]',
+        ''
+      ])
+    })
+
+    it('supports a "visit" function option', function () {
+      var commandObject
+      var pathToFile
+      var filename
+      var r = checkOutput(function () {
+        return yargs('--help').help().wrap(null)
+          // assumes cwd is node_modules/mocha/bin
+          .commandDir('../../../test/fixtures/cmddir', {
+            visit: function (_commandObject, _pathToFile, _filename) {
+              commandObject = _commandObject
+              pathToFile = _pathToFile
+              filename = _filename
+              return false // exclude command
+            }
+          })
+          .argv
+      })
+      commandObject.should.have.property('command').and.equal('dream [command] [opts]')
+      commandObject.should.have.property('desc').and.equal('Go to sleep and dream')
+      commandObject.should.have.property('builder')
+      commandObject.should.have.property('handler')
+      pathToFile.should.contain(require('path').join('test', 'fixtures', 'cmddir', 'dream.js'))
+      filename.should.equal('dream.js')
+      r.should.have.property('exit').and.be.true
+      r.should.have.property('errors').with.length(0)
+      r.should.have.property('logs')
+      r.logs.join('\n').split(/\n+/).should.deep.equal([
+        'Options:',
+        '  --help  Show help  [boolean]',
+        ''
+      ])
+    })
+
+    it('detects and ignores cyclic dir references', function () {
+      var r = checkOutput(function () {
+        return yargs('cyclic --help').help().wrap(null)
+          // assumes cwd is node_modules/mocha/bin
+          .commandDir('../../../test/fixtures/cmddir_cyclic')
+          .argv
+      }, [ './command' ])
+      r.should.have.property('exit').and.be.true
+      r.should.have.property('errors').with.length(0)
+      r.should.have.property('logs')
+      r.logs.join('\n').split(/\n+/).should.deep.equal([
+        './command cyclic',
+        'Options:',
+        '  --help  Show help  [boolean]',
+        ''
+      ])
+    })
+  })
 })
diff --git a/test/fixtures/cmddir/deep/deeper/deeper_still/limbo.js b/test/fixtures/cmddir/deep/deeper/deeper_still/limbo.js
new file mode 100644
index 0000000..9b8d0cc
--- /dev/null
+++ b/test/fixtures/cmddir/deep/deeper/deeper_still/limbo.js
@@ -0,0 +1,28 @@
+exports.command = 'limbo [opts]'
+exports.desc = 'Get lost in pure subconscious'
+exports.builder = {
+  'with-self-exit': {
+    desc: 'Pretty much your only way out',
+    type: 'boolean'
+  },
+  'with-totem': {
+    desc: 'Take your totem with you',
+    type: 'boolean'
+  }
+}
+exports.handler = function (argv) {
+  var factor = 3
+  if (!argv.withSelfExit) throw new Error('You entered limbo without a way out!')
+  if (!argv.withTotem) factor -= 2
+  if (argv.extract) {
+    if (!chancesLevel4(factor)) throw new Error('You didn\'t have much chance anyway, you\'re stuck in limbo!')
+    if (!argv._msg) argv._msg = 'You have accomplished the impossible. Inception successful.'
+    return
+  }
+  if (!chancesLevel4(factor)) throw new Error('You rolled the dice and lost, you\'re stuck in limbo!')
+  if (!argv._msg) argv._msg = 'Can you ever be sure of what\'s real anymore?'
+}
+
+function chancesLevel4 (factor) {
+  return Math.floor(Math.random() * 10) < factor
+}
diff --git a/test/fixtures/cmddir/deep/deeper/inception.js b/test/fixtures/cmddir/deep/deeper/inception.js
new file mode 100644
index 0000000..4ee5e74
--- /dev/null
+++ b/test/fixtures/cmddir/deep/deeper/inception.js
@@ -0,0 +1,32 @@
+exports.command = 'inception [command] [opts]'
+exports.desc = 'Enter another dream, where inception is possible'
+exports.builder = function (yargs) {
+  return yargs
+    .commandDir('deeper_still')
+    .option('with-sedation', {
+      desc: 'Apply a sedative?',
+      type: 'boolean',
+      global: true
+    })
+    .option('with-timed-kick', {
+      desc: 'Plan an elaborate timed kick at each layer?',
+      type: 'boolean',
+      global: true
+    })
+}
+exports.handler = function (argv) {
+  var factor = 5
+  if (argv.extract) {
+    if (!argv.withSedation) factor -= 1
+    if (!argv.withTimedKick) factor -= 1
+    if (!chancesLevel3(factor)) throw new Error('Something went wrong at level 3! Check your options for increased chance of success.')
+    if (!argv._msg) argv._msg = 'You have narrowly escaped disaster. Inception successful.'
+    return
+  }
+  if (!chancesLevel3(factor)) throw new Error('You can no longer tell a dream from reality!')
+  if (!argv._msg) argv._msg = 'Be very careful, you\'re starting to lose grip on reality.'
+}
+
+function chancesLevel3 (factor) {
+  return Math.floor(Math.random() * 10) < factor
+}
diff --git a/test/fixtures/cmddir/deep/of-memory.json b/test/fixtures/cmddir/deep/of-memory.json
new file mode 100644
index 0000000..bcd1451
--- /dev/null
+++ b/test/fixtures/cmddir/deep/of-memory.json
@@ -0,0 +1,4 @@
+{
+  "command": "of-memory <memory>",
+  "desc": "Dream about a specific memory"
+}
diff --git a/test/fixtures/cmddir/deep/within-a-dream.js b/test/fixtures/cmddir/deep/within-a-dream.js
new file mode 100644
index 0000000..af9e7f7
--- /dev/null
+++ b/test/fixtures/cmddir/deep/within-a-dream.js
@@ -0,0 +1,27 @@
+module.exports = {
+  command: 'within-a-dream [command] [opts]',
+  desc: 'Dream within a dream',
+  builder: function (yargs) {
+    return yargs
+      .commandDir('deeper')
+      .option('with-kick', {
+        desc: 'Plan a kick for controlled wake up?',
+        type: 'boolean',
+        global: true
+      })
+  },
+  handler: function (argv) {
+    var factor = 7
+    if (argv.extract) {
+      if (!argv.withKick) factor -= 2
+      if (!chancesLevel2(factor)) throw new Error('Something went wrong at level 2! Check your options for increased chance of success.')
+      if (!argv._msg) argv._msg = 'You got lucky this time. Extraction successful.'
+      return
+    }
+    if (!argv._msg) argv._msg = 'Let\'s not make a habit of this.'
+  }
+}
+
+function chancesLevel2 (factor) {
+  return Math.floor(Math.random() * 10) < factor
+}
diff --git a/test/fixtures/cmddir/dream.js b/test/fixtures/cmddir/dream.js
new file mode 100644
index 0000000..d66cef6
--- /dev/null
+++ b/test/fixtures/cmddir/dream.js
@@ -0,0 +1,31 @@
+exports.command = 'dream [command] [opts]'
+exports.desc = 'Go to sleep and dream'
+exports.builder = function (yargs) {
+  return yargs
+    .commandDir('deep', {
+      extensions: ['js', 'json']
+    })
+    .option('shared', {
+      desc: 'Is the dream shared with others?',
+      type: 'boolean',
+      global: true
+    })
+    .option('extract', {
+      desc: 'Attempt extraction?',
+      type: 'boolean',
+      global: true
+    })
+}
+exports.handler = function (argv) {
+  if (argv.extract) {
+    if (!argv.shared) throw new Error('Dream is not shared, there is no one to extract from!')
+    if (!chancesLevel1()) throw new Error('Extraction failed!')
+    if (!argv._msg) argv._msg = 'Extraction succesful'
+  }
+  if (argv._msg) console.log(argv._msg)
+  else console.log(argv.shared ? 'Training session over' : 'Well, that was a refreshing nap')
+}
+
+function chancesLevel1 () {
+  return Math.floor(Math.random() * 10) < 9
+}
diff --git a/test/fixtures/cmddir_cyclic/cyclic.js b/test/fixtures/cmddir_cyclic/cyclic.js
new file mode 100644
index 0000000..b506260
--- /dev/null
+++ b/test/fixtures/cmddir_cyclic/cyclic.js
@@ -0,0 +1,6 @@
+exports.command = 'cyclic'
+exports.description = 'Attempts to (re)apply its own dir'
+exports.builder = function (yargs) {
+  return yargs.commandDir('../cmddir_cyclic')
+}
+exports.handler = function (argv) {}