├── .gitignore ├── .travis.yml ├── LICENSE ├── index.js ├── package.json ├── readme.md └── test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | package-lock.json -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 'lts/*' 4 | - '8' 5 | - 'node' 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Doctrine 2 | Copyright Max Ogden 2016 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above copyright 10 | notice, this list of conditions and the following disclaimer in the 11 | documentation and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 15 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 16 | ARE DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY 17 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 18 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 19 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 20 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 21 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 22 | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var minimist = require('minimist') 2 | var cliclopts = require('cliclopts') 3 | var xtend = Object.assign 4 | var debug = require('debug')('subcommand') 5 | 6 | module.exports = function subcommand (config, options) { 7 | if (!options) options = {} 8 | if (Array.isArray(config)) { 9 | config = { commands: config } 10 | } 11 | if (!config.commands) config.commands = [] 12 | if (!config.defaults) config.defaults = [] 13 | if (config.usage && !config.usage.option) { 14 | if (typeof config.usage !== 'object') config.usage = {} // allow config.usage = true 15 | config.usage.option = { name: 'help', abbr: 'h' } 16 | } 17 | // return value false means it was not handleds 18 | // return value true means it was 19 | return function matcher (args) { 20 | var root = config.root 21 | if (root && root.options) { 22 | var rootClic = cliclopts(config.defaults.concat(root.options)) 23 | var rootOpts = rootClic.options() 24 | } 25 | var parseOpts = xtend(options.minimistOpts || {}, rootOpts) 26 | var argv = minimist(args, parseOpts) 27 | debug('parsed', argv) 28 | var sub = findCommand(argv._, config.commands) 29 | if (config.all) config.all(argv) 30 | if (!sub) { 31 | if (config.usage && (argv[config.usage.option.name] || argv[config.usage.option.abbr])) { 32 | debug('Printing general usage') 33 | if (config.usage.command) config.usage.command(argv, config.usage.help, rootClic.usage()) 34 | else { 35 | if (config.usage.help) process.stdout.write(config.usage.help + '\n') 36 | process.stdout.write(rootClic.usage()) 37 | } 38 | return true 39 | } 40 | if (argv._.length === 0 && root && root.command) { 41 | root.command(argv) 42 | return true 43 | } 44 | if (config.none) config.none(argv) 45 | return false 46 | } 47 | var subMinimistOpts = {} 48 | var subOpts = config.defaults.concat(sub.command.options || []) 49 | var subClic = cliclopts(subOpts) 50 | subMinimistOpts = subClic.options() 51 | var subargv = minimist(args, subMinimistOpts) 52 | subargv._ = subargv._.slice(sub.commandLength) 53 | if (config.usage && (subargv[config.usage.option.name] || subargv[config.usage.option.abbr])) { 54 | debug('Printing subcommand usage') 55 | if (sub.command.usage) sub.command.usage(subargv, sub.command.help, subClic.usage()) 56 | else { 57 | if (sub.command.help) process.stdout.write(sub.command.help + '\n') 58 | process.stdout.write(subClic.usage()) 59 | } 60 | return true 61 | } 62 | process.nextTick(function doCb () { 63 | sub.command.command(subargv, subClic) 64 | }) 65 | return true 66 | } 67 | } 68 | 69 | function findCommand (args, commands) { 70 | var match, commandLength 71 | commands 72 | .map(function each (c, idx) { 73 | // turn e.g. 'foo bar' into ['foo', 'bar'] 74 | return { name: c.name.split(' '), index: idx } 75 | }) 76 | .sort(function each (a, b) { 77 | return a.name.length > b.name.length 78 | }) 79 | .forEach(function eachCommand (c) { 80 | var cmdString = JSON.stringify(c.name) 81 | var argString = JSON.stringify(args.slice(0, c.name.length)) 82 | if (cmdString === argString) { 83 | match = commands[c.index] 84 | commandLength = c.name.length 85 | } 86 | }) 87 | var returnData = { command: match, commandLength: commandLength } 88 | debug('match', match) 89 | if (match) return returnData 90 | else return false 91 | } 92 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "subcommand", 3 | "version": "2.1.1", 4 | "description": "create CLI tools with subcommands", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "standard && node test.js" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/maxogden/subcommand.git" 12 | }, 13 | "keywords": [ 14 | "minimist" 15 | ], 16 | "author": "max ogden", 17 | "license": "BSD-2-Clause", 18 | "bugs": { 19 | "url": "https://github.com/maxogden/subcommand/issues" 20 | }, 21 | "homepage": "https://github.com/maxogden/subcommand", 22 | "dependencies": { 23 | "cliclopts": "^1.1.0", 24 | "debug": "^4.1.1", 25 | "minimist": "^1.2.0" 26 | }, 27 | "devDependencies": { 28 | "standard": "^12.0.1", 29 | "tape": "^4.0.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # subcommand 2 | 3 | Create CLI tools with subcommands. A minimalist CLI router based on [minimist](https://www.npmjs.com/package/minimist) and [cliclopts](https://www.npmjs.com/package/cliclopts). 4 | 5 | [![NPM](https://nodei.co/npm/subcommand.png)](https://nodei.co/npm/subcommand/) 6 | 7 | [![js-standard-style](https://raw.githubusercontent.com/feross/standard/master/badge.png)](https://github.com/feross/standard) 8 | 9 | [![Build Status](https://travis-ci.org/maxogden/subcommand.svg?branch=master)](https://travis-ci.org/maxogden/subcommand) 10 | 11 | ## basic usage 12 | 13 | first, define your CLI API in JSON like this: 14 | 15 | ```js 16 | var commands = [ 17 | { 18 | name: 'foo', 19 | options: [ // cliclopts options 20 | { 21 | name: 'loud', 22 | boolean: true, 23 | default: false, 24 | abbr: 'v', 25 | help: 'print out all output loudly' 26 | } 27 | ], 28 | command: function foo (args) { 29 | // called when `foo` is matched 30 | } 31 | }, 32 | { 33 | name: 'bar', 34 | command: function bar (args) { 35 | // called when `bar` is matched 36 | } 37 | } 38 | ] 39 | ``` 40 | 41 | then pass them into `subcommand`: 42 | 43 | ```js 44 | var subcommand = require('subcommand') 45 | var match = subcommand(config, opts) 46 | ``` 47 | 48 | `subcommand` returns a function (called `match` above) that you can use to match/route arguments to their subcommands 49 | 50 | the return value will be `true` if a subcommand was matched, or `false` if no subcommand was matched 51 | 52 | ```js 53 | var matched = match(['foo']) 54 | // matched will be true, and foo's `command` function will be called 55 | 56 | var matched = match(['foo', 'baz', 'taco']) 57 | // matched will be true, and foo's `command` function will be called with `['baz', 'taco']` 58 | 59 | var matched = match(['bar']) 60 | // matched will be true, and bar's `command` function will be called 61 | 62 | var matched = match(['uhoh']) 63 | // matched will be false 64 | ``` 65 | 66 | ## advanced usage 67 | 68 | instead of an array, you can also pass an object that looks like this as the first argument to `subcommand`: 69 | 70 | ``` 71 | { 72 | root: // root command options and handler 73 | defaults: // default options 74 | all: // function that gets called always, regardless of match or no match 75 | none: // function that gets called only when there is no matched subcommand 76 | usage: // subcommand to use for printing usage 77 | commands: // the commands array from basic usage 78 | } 79 | ``` 80 | 81 | see `test.js` for a concrete example 82 | 83 | ### root 84 | 85 | to pass options to the 'root' command (e.g. when no subcommand is passed in), set up your config like this: 86 | 87 | ```js 88 | var config = { 89 | root: { 90 | options: [ // cliclopts options 91 | { 92 | name: 'loud', 93 | boolean: true, 94 | default: false, 95 | abbr: 'v' 96 | } 97 | ], 98 | command: function (args) { 99 | // called when no subcommand is specified 100 | } 101 | }, 102 | commands: yourSubCommandsArray 103 | } 104 | ``` 105 | 106 | ### defaults 107 | 108 | you can pass in a defaults options array, and all subcommands as well as the root command will inherit the default options 109 | 110 | ```js 111 | var config = { 112 | defaults: [ 113 | {name: 'path', default: process.cwd()} // all commands (and root) will now always have a 'path' default option 114 | ], 115 | commands: yourSubCommandsArray 116 | } 117 | ``` 118 | 119 | ### all 120 | 121 | pass a function under the `all` key and it will get called with the parsed arguments 100% of the time 122 | 123 | ```js 124 | var config = { 125 | all: function all (args) { /** will be called always in addition to the command/root `command` handlers **/ }, 126 | commands: yourSubCommandsArray 127 | } 128 | ``` 129 | 130 | ### none 131 | 132 | pass a function under the `none` key and it will get called when no subcommand is matched 133 | 134 | ```js 135 | var config = { 136 | none: function none (args) { /** will only be called when no subcommand is matched **/ }, 137 | commands: yourSubCommandsArray 138 | } 139 | ``` 140 | 141 | ### usage 142 | 143 | The `usage` option makes it easy to print [cliclops usage](https://github.com/finnp/cliclopts#clioptsusage) for the root command and subcommands. 144 | 145 | #### Basic usage 146 | 147 | By default, usage is printed with the `--help` or `-h` option. Set usage to true to print `cliclops.usage()` with `--help`: 148 | 149 | ```js 150 | var config = { 151 | usage: true 152 | } 153 | ``` 154 | 155 | Use `usage.help` to print information above `cliclops.usage()`. Change the name of the usage option by specifying `usage.option`: 156 | 157 | ```js 158 | var config = { 159 | usage: { 160 | help: 'general usage info', 161 | option: { 162 | name: 'info', 163 | abbr: 'i' 164 | } 165 | } 166 | } 167 | ``` 168 | 169 | This will print the usage with `--info` or `-i` instead of `--help`. The option is used for the root and subcommands. 170 | 171 | #### Advanced Usage 172 | 173 | You can also define custom usage functions for the root and subcommands. These are passed the help text and `cliclops.usage()`. 174 | 175 | ```js 176 | var config = { 177 | usage: { 178 | help: 'general help message', // Message to print before cliclops.usage() 179 | option: { 180 | // minimist option to use for printing usage 181 | name: 'help', 182 | abbr: 'h' 183 | }, 184 | command: function (args, help, usage) { 185 | // optional function to print usage. 186 | console.log(help) // prints: "general help message" 187 | console.log(usage) // prints: cliclops.usage() 188 | } 189 | }, 190 | commands: [{ 191 | name: 'foo', 192 | help: 'foo help message', 193 | options: [ 194 | { 195 | name: 'loud', 196 | help: 'print out all output loudly' 197 | } 198 | ], 199 | usage: function (args, help, usage) { 200 | // called when `foo` is matched and --help option is used 201 | console.log(help) // prints: "foo help message" 202 | console.log(usage) // prints: cliclops.usage() 203 | }, 204 | command: function foo (args) { 205 | // called when `foo` is matched 206 | } 207 | }] 208 | } 209 | ``` 210 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | var test = require('tape') 2 | var sub = require('./') 3 | 4 | function testCommands (onMatch, onAll, onNone, onUsage) { 5 | var config = { 6 | root: { 7 | options: [{ 8 | name: 'version', 9 | boolean: true, 10 | abbr: 'v', 11 | help: 'version option' 12 | }], 13 | command: function noCommand (args) { 14 | onMatch('noCommand', args) 15 | } 16 | }, 17 | defaults: [{ 18 | name: 'taco', 19 | boolean: true, 20 | abbr: 't' 21 | }], 22 | all: onAll, 23 | none: onNone, 24 | usage: { 25 | help: 'this is the general help', 26 | option: { 27 | name: 'help', 28 | abbr: 'h' 29 | }, 30 | command: onUsage 31 | }, 32 | commands: [ 33 | { 34 | name: 'cat', 35 | help: 'cat meow', 36 | usage: onUsage, 37 | options: [ 38 | { 39 | name: 'live', 40 | boolean: true, 41 | default: true, 42 | abbr: 'l', 43 | help: 'live option' 44 | }, 45 | { 46 | name: 'format', 47 | boolean: false, 48 | default: 'csv', 49 | abbr: 'f' 50 | } 51 | ], 52 | command: function cat (args) { 53 | onMatch('cat', args) 54 | } 55 | }, 56 | { 57 | name: 'cat foo', 58 | command: function catFoo (args) { 59 | onMatch('cat foo', args) 60 | } 61 | }, 62 | { 63 | name: 'cat foo bar', 64 | command: function catFooBar (args) { 65 | onMatch('cat foo bar', args) 66 | } 67 | } 68 | ] 69 | } 70 | 71 | return config 72 | } 73 | 74 | test('match basic subcommand with no args', function (t) { 75 | var args = sub([{ 76 | name: 'foo', 77 | command: function foo (args) { 78 | t.equal(args._.length, 0, 'no args') 79 | t.end() 80 | } 81 | }]) 82 | var handled = args(['foo']) 83 | t.equal(handled, true, 'returned true') 84 | }) 85 | 86 | test('match basic subcommand with 1 extra arg', function (t) { 87 | var args = sub([{ 88 | name: 'foo', 89 | command: function foo (args) { 90 | t.equal(args._.length, 1, '1 arg') 91 | t.equal(args._[0], 'bar', 'bar') 92 | t.end() 93 | } 94 | }]) 95 | var handled = args(['foo', 'bar']) 96 | t.equal(handled, true, 'returned true') 97 | }) 98 | 99 | test('match basic subcommand with 5 extra args', function (t) { 100 | var args = sub([{ 101 | name: 'foo', 102 | command: function foo (args) { 103 | t.equal(args._.length, 5, '5 args') 104 | t.equal(JSON.stringify(args._), JSON.stringify(['bar', 'taco', 'pizza', 'walrus', 'muffin']), 'args match') 105 | t.end() 106 | } 107 | }]) 108 | var handled = args(['foo', 'bar', 'taco', 'pizza', 'walrus', 'muffin']) 109 | t.equal(handled, true, 'returned true') 110 | }) 111 | 112 | test('match 1 arg command w/ 1 extra arg', function (t) { 113 | function onMatch (matched, args) { 114 | t.equal(matched, 'cat') 115 | t.equal(args._.length, 1, '1 arg') 116 | t.equal(args._[0], 'taco', 'taco') 117 | t.end() 118 | } 119 | var args = sub(testCommands(onMatch)) 120 | var handled = args(['cat', 'taco']) 121 | t.equal(handled, true, 'returned true') 122 | }) 123 | 124 | test('match 2 arg command w/ 1 extra arg', function (t) { 125 | function onMatch (matched, args) { 126 | t.equal(matched, 'cat foo') 127 | t.equal(args._.length, 1, '1 arg') 128 | t.equal(args._[0], 'baz', 'baz') 129 | t.end() 130 | } 131 | var args = sub(testCommands(onMatch)) 132 | var handled = args(['cat', 'foo', 'baz']) 133 | t.equal(handled, true, 'returned true') 134 | }) 135 | 136 | test('match 3 arg command w/ 1 extra arg', function (t) { 137 | function onMatch (matched, args) { 138 | t.equal(matched, 'cat foo bar') 139 | t.equal(args._.length, 1, '1 arg') 140 | t.equal(args._[0], 'muffin', 'muffin') 141 | t.end() 142 | } 143 | var args = sub(testCommands(onMatch)) 144 | var handled = args(['cat', 'foo', 'bar', 'muffin']) 145 | t.equal(handled, true, 'returned true') 146 | }) 147 | 148 | test('match top level option using abbr', function (t) { 149 | function onMatch (matched, args) { 150 | t.equal(matched, 'noCommand') 151 | t.equal(args.version, true, 'got version') 152 | t.end() 153 | } 154 | var args = sub(testCommands(onMatch)) 155 | var handled = args(['-v']) 156 | t.equal(handled, true, 'returned true') 157 | }) 158 | 159 | test('default options', function (t) { 160 | function onMatch (matched, args) { 161 | t.equal(matched, 'noCommand') 162 | t.equal(args.taco, true, 'got taco') 163 | t.end() 164 | } 165 | var args = sub(testCommands(onMatch)) 166 | var handled = args(['-t']) 167 | t.equal(handled, true, 'returned true') 168 | }) 169 | 170 | test('commands with no options still get defaults', function (t) { 171 | function onMatch (matched, args) { 172 | t.equal(matched, 'cat foo') 173 | t.equal(args.taco, true, 'got taco') 174 | t.end() 175 | } 176 | var args = sub(testCommands(onMatch)) 177 | var handled = args(['cat', 'foo', '-t']) 178 | t.equal(handled, true, 'returned true') 179 | }) 180 | 181 | test('default options are overridden', function (t) { 182 | var args = sub({ 183 | defaults: [{ name: 'foo', default: 'donkey' }], 184 | root: { 185 | options: [{ name: 'foo', default: 'pizza' }], 186 | command: function root (args) { 187 | t.equal(args.foo, 'pizza', 'pizza') 188 | t.end() 189 | } 190 | } 191 | }) 192 | var handled = args([]) 193 | t.equal(handled, true, 'returned true') 194 | }) 195 | 196 | test('all handler', function (t) { 197 | t.plan(6) 198 | function onMatch (matched, args) { 199 | t.ok(true, 'called onMatch') 200 | } 201 | function onAll (args) { 202 | t.ok(true, 'called onAll') 203 | } 204 | var args = sub(testCommands(onMatch, onAll)) 205 | var handled = args(['--foo', 'bar']) 206 | t.equal(handled, true, 'returned true') 207 | var handled2 = args(['cat', 'taco']) 208 | t.equal(handled2, true, 'returned true') 209 | }) 210 | 211 | test('none handler', function (t) { 212 | t.plan(5) 213 | function onMatch (matched, args) { 214 | t.ok(true, 'called onMatch') 215 | } 216 | function onNone (args) { 217 | t.ok(true, 'called onNone') 218 | t.equal(args._[0], 'buffalo', 'buffalo') 219 | } 220 | var args = sub(testCommands(onMatch, null, onNone)) 221 | var handled = args(['cat']) 222 | t.equal(handled, true, 'returned true') 223 | var handled2 = args(['buffalo', 'wings']) 224 | t.equal(handled2, false, 'returned true') 225 | }) 226 | 227 | test('usage handler', function (t) { 228 | t.plan(5) 229 | function onUsage (args, help, usage) { 230 | t.ok(true, 'called onUsage') 231 | t.ok(help, 'has general help') 232 | t.ok(usage, 'has cliclops usage') 233 | t.ok(usage.indexOf('version option') > -1, 'has version help') 234 | } 235 | var args = sub(testCommands(null, null, null, onUsage)) 236 | var handled = args(['--help']) 237 | t.equal(handled, true, 'returned true') 238 | }) 239 | 240 | test('subcommand usage handler', function (t) { 241 | t.plan(5) 242 | function onUsage (args, help, usage) { 243 | t.ok(true, 'called onUsage') 244 | t.ok(help, 'has general help') 245 | t.ok(usage, 'has cliclops usage') 246 | t.ok(usage.indexOf('live option') > -1, 'has live help') 247 | } 248 | var args = sub(testCommands(null, null, null, onUsage)) 249 | var handled = args(['cat', '--help']) 250 | t.equal(handled, true, 'returned true') 251 | }) 252 | --------------------------------------------------------------------------------