diff --git a/.gitignore b/.gitignore index 7e36921c1f5..61808594e02 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,6 @@ dist .DS_Store coverage # generated docs -docs/manual/site \ No newline at end of file +docs/manual/site +# generated commands info +commands.json \ No newline at end of file diff --git a/.npmignore b/.npmignore index c201f7889c8..3fc7304f453 100644 --- a/.npmignore +++ b/.npmignore @@ -9,6 +9,7 @@ src types .gitignore CONTRIBUTING.md +commands.json tsconfig.json *.map *.spec.js \ No newline at end of file diff --git a/docs/manual/docs/concepts/completion.md b/docs/manual/docs/concepts/completion.md new file mode 100644 index 00000000000..274e252b3f5 --- /dev/null +++ b/docs/manual/docs/concepts/completion.md @@ -0,0 +1,64 @@ +# Command completion + +To help you use its commands, the Office 365 CLI offers you the ability to autocomplete commands and options that you're typing in the prompt. Depending how you're using the Office 365 CLI, some additional setup might be required to enable command completion. + +## Immersive mode + +One way to use the Office 365 CLI is to start it in the immersive mode. By typing in your shell `o365` or `office365`, you start the Office 365 CLI and your command prompt changes to `o365$`. At that point, the Office 365 CLI takes over your shell and interprets all of your input. To complete the command you're typing, simply start typing the word and press `TAB`. To see the list of available commands, matching your input, press `TAB` twice. + +## Non-immersive mode + +When using the Office 365 CLI in the non-immersive mode, you execute complete Office 365 CLI commands in your shell. Rather than starting the Office 365 CLI by typing `o365` or `office365`, you type the whole command, like `o365 spo app list`. Also when running in non-immersive mode, the Office 365 CLI offers you support for completing your input. The configuration steps required to enable command completion, depend on which operating system and shell you're using. + +### Clink (cmder) + +On Windows, the Office 365 CLI offers support for completing commands in [cmder](http://cmder.net) and other shells using [Clink](https://mridgers.github.io/clink/). + +#### Enable Clink completion + +To enable completion: + +1. Start your shell +1. Change the working directory to where your shell stores completion plugins. For cmder, it's `%CMDER_ROOT%\vendor\clink-completions`, where `%CMDER_ROOT%` is the folder where you installed cmder. +1. Execute: `o365 --completion:clink:generate > o365.lua`. This will create the `o365.lua` file with information about o365 commands which is used by Clink to provide completion +1. Restart your shell + +You should now be able to complete your input, eg. typing `o365 s` will complete it to `o365 spo` and typing `o365 spo ` will list all SharePoint Online commands available in Office 365 CLI. To see the options available for the current command, type `-`, for example `o365 spo app list -` will list all options available for the `o365 spo app list` command. + +#### Disable Clink completion + +To disable completion, delete the `o365.lua` file you generated previously and restart your shell. + +#### Update Clink completion + +Command completion is based on a static file. After updating the Office 365 CLI, you should update the completion file as described in the [Enable completion](#enable-clink-completion) section so that the completion file reflects the latest commands in the Office 365 CLI. + +### Zsh, Bash and Fish + +If you're using Zsh, Bash or Fish as your shell, you can benefit of Office 365 CLI command completion as well, when typing commands directly in the shell. The completion is based on the [Omelette](https://www.npmjs.com/package/omelette) package. + +#### Enable sh completion + +To enable completion: + +1. Start your shell +1. Execute `o365 --completion:sh:setup`. This will generate the `commands.json` file in the same folder where the Office 365 CLI is installed, listing all available commands and their options. Additionally, it will register completion in your shell profile file (for Zsh `~/.zshrc`) using the [Omelette's automated install](https://www.npmjs.com/package/omelette#automated-install). +1. Restart your shell + +You should now be able to complete your input, eg. typing `o365 s` will complete it to `o365 spo` and typing `o365 spo ` will list all SharePoint Online commands available in Office 365 CLI. To see the options available for the command, type `-`, for example `o365 spo app list -` will list all options available for the `o365 spo app list` command. If the command is completed, the completion will automatically start suggestions with a `-` indicating that you have matched a command and can now specify its options. Command options you've already used are removed from the suggestions list, but the completion doesn't take into account short and long variant of the same option. If you specified the `--output` option in your command, `--option` will not be displayed in the list of suggestions, but `-o` will. + +#### Disable sh completion + +To disable completion, edit your shell's profile file (for Zsh `~/.zshrc`) and remove the following lines: + +```sh +# begin o365 completion +. <(o365 --completion) +# end o365 completion +``` + +Save the profile file and restart the shell for the changes to take effect. + +#### Update sh completion + +Command completion is based on the static `commands.json` file located in the folder where the Office 365 CLI is installed. After updating the Office 365 CLI, you should update the completion file by executing `o365 --completion:sh:generate` in the command line. After running this command, it's not necessary to restart the shell to see the latest changes. \ No newline at end of file diff --git a/docs/manual/mkdocs.yml b/docs/manual/mkdocs.yml index 75aaf26a461..8a58a3a0925 100644 --- a/docs/manual/mkdocs.yml +++ b/docs/manual/mkdocs.yml @@ -59,6 +59,7 @@ pages: - Concepts: - 'Persisting connection': 'concepts/persisting-connection.md' - 'Authorization and access tokens': 'concepts/authorization-tokens.md' + - 'Command completion': 'concepts/completion.md' - About: - 'Why this CLI': 'about/why-cli.md' - 'Comparison to SharePoint PowerShell': 'about/comparison-powershell.md' diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 503acd8337a..3f9e15287aa 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -2443,6 +2443,11 @@ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" }, + "omelette": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/omelette/-/omelette-0.4.5.tgz", + "integrity": "sha512-b0k9uqwF60u15KmVkneVw96VYRtZu2QCbXUQ26SgdyVUgMBzctzIfhNPKAWl4oqJEKpe52CzBYSS+HIKtiK8sw==" + }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", diff --git a/package.json b/package.json index 74f2114708b..d6235cd2aba 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "dependencies": { "applicationinsights": "^1.0.1", "easy-table": "^1.1.0", + "omelette": "^0.4.5", "request-promise-native": "^1.0.5", "vorpal": "^1.12.0" }, diff --git a/src/autocomplete.ts b/src/autocomplete.ts new file mode 100644 index 00000000000..5f4df2f39de --- /dev/null +++ b/src/autocomplete.ts @@ -0,0 +1,222 @@ +const omelette: (template: string) => Omelette = require('omelette'); +import * as os from 'os'; +import * as fs from 'fs'; +import * as path from 'path'; + +class Autocomplete { + private static autocompleteFilePath: string = path.join(__dirname, `..${path.sep}commands.json`); + private omelette: Omelette; + private commands: any = {}; + + constructor() { + this.init(); + } + + private init(): void { + if (fs.existsSync(Autocomplete.autocompleteFilePath)) { + try { + const data: string = fs.readFileSync(Autocomplete.autocompleteFilePath, 'utf-8'); + this.commands = JSON.parse(data); + } + catch { } + } + + const _this = this; + + function handleAutocomplete(this: any, fragment: string, data: any): void { + let replies: Object | string[] = {}; + let allWords: string[] = []; + + if (data.fragment === 1) { + replies = Object.keys(_this.commands); + } + else { + allWords = data.line.split(/\s+/).slice(1, -1); + // build array of words to use as a path to retrieve completion + // options from the commands tree + const words: string[] = allWords + .filter((e: string, i: number): boolean => { + if (e.indexOf('-') !== 0) { + // if the word is not an option check if it's not + // option's value, eg. --output json, in which case + // the suggestion should be command options + return i === 0 || allWords[i - 1].indexOf('-') !== 0; + } + else { + // remove all options but last one + return i === allWords.length - 1; + } + }); + let accessor: Function = new Function('_', "return _['" + (words.join("']['")) + "']"); + + replies = accessor(_this.commands); + // if the last word is an option without autocomplete + // suggest other options from the same command + if (words[words.length - 1].indexOf('-') === 0 && + !Array.isArray(replies)) { + accessor = new Function('_', "return _['" + (words.filter(w => w.indexOf('-') !== 0).join("']['")) + "']"); + replies = accessor(_this.commands); + + if (!Array.isArray(replies)) { + replies = Object.keys(replies); + } + } + } + + if (!Array.isArray(replies)) { + replies = Object.keys(replies); + } + + // remove options that already have been used + replies = (replies as string[]).filter(r => r.indexOf('-') !== 0 || allWords.indexOf(r) === -1); + + this.reply(replies); + } + + this.omelette = omelette('o365|office365'); + this.omelette.on('complete', handleAutocomplete); + this.omelette.init(); + } + + public generateShCompletion(vorpal: Vorpal): void { + const commandsInfo: any = this.getCommandsInfo(vorpal); + fs.writeFileSync(Autocomplete.autocompleteFilePath, JSON.stringify(commandsInfo)); + } + + public setupShCompletion(): void { + this.omelette.setupShellInitFile(); + } + + public getClinkCompletion(vorpal: Vorpal): string { + const cmd: any = this.getCommandsInfo(vorpal); + const lua: string[] = ['local parser = clink.arg.new_parser']; + + this.buildClinkForBranch(cmd, lua, 'o365'); + + lua.push( + '', + 'clink.arg.register_parser("o365", o365_parser)', + 'clink.arg.register_parser("office365", o365_parser)' + ); + + return lua.join(os.EOL); + } + + private buildClinkForBranch(branch: any, lua: string[], luaFunctionName: string): void { + if (!Array.isArray(branch)) { + const keys: string[] = Object.keys(branch); + + if (keys.length > 0) { + keys.forEach(k => { + if (Object.keys(branch[k]).length > 0) { + this.buildClinkForBranch(branch[k], lua, this.getLuaFunctionName(`${luaFunctionName}_${k}`)); + } + }); + } + } + + lua.push( + '', + `local ${luaFunctionName}_parser = parser({` + ); + + let printingArgs: boolean = false; + + if (Array.isArray(branch)) { + if (branch.find(c => c.indexOf('-') === 0)) { + printingArgs = true; + lua.push(`},${branch.map(c => `"${c}"`).join(',')}`); + } + else { + branch.sort().forEach((c, i) => { + const separator = i < branch.length - 1 ? ',' : ''; + lua.push(` "${c}"${separator}`); + }); + } + } + else { + const keys = Object.keys(branch); + if (keys.find(c => c.indexOf('-') === 0)) { + printingArgs = true; + const tmp: string[] = []; + keys.sort().forEach((k, i) => { + if (Object.keys(branch[k]).length > 0) { + tmp.push(`"${k}"..${this.getLuaFunctionName(`${luaFunctionName}_${k}_parser`)}`); + } + else { + tmp.push(`"${k}"`); + } + }); + + lua.push(`},${tmp.join(',')}`); + } + else { + keys.sort().forEach((k, i) => { + const separator = i < keys.length - 1 ? ',' : ''; + if (Object.keys(branch[k]).length > 0) { + lua.push(` "${k}"..${this.getLuaFunctionName(`${luaFunctionName}_${k}_parser`)}${separator}`); + } + else { + lua.push(` "${k}"${separator}`); + } + }); + } + } + + lua.push(`${printingArgs ? '' : '}'})`); + } + + private getLuaFunctionName(functionName: string): string { + return functionName.replace(/-/g, '_'); + } + + private getCommandsInfo(vorpal: Vorpal): any { + const commandsInfo: any = {}; + const commands: CommandInfo[] = vorpal.commands; + const visibleCommands: CommandInfo[] = commands.filter(c => !c._hidden); + visibleCommands.forEach(c => { + Autocomplete.processCommand(c._name, c, commandsInfo); + c._aliases.forEach(a => Autocomplete.processCommand(a, c, commandsInfo)); + }); + + return commandsInfo; + } + + private static processCommand(commandName: string, commandInfo: CommandInfo, autocomplete: any) { + const chunks: string[] = commandName.split(' '); + let parent: any = autocomplete; + for (let i: number = 0; i < chunks.length; i++) { + const current: any = chunks[i]; + if (current === 'exit' || current === 'quit') { + continue; + } + + if (!parent[current]) { + if (i < chunks.length - 1) { + parent[current] = {}; + } + else { + // last chunk, add options + const optionsArr: string[] = commandInfo.options.map(o => o.short) + .concat(commandInfo.options.map(o => o.long)).filter(o => o != null); + optionsArr.push('--help'); + const optionsObj: any = {}; + optionsArr.forEach(o => { + const option: CommandOption = commandInfo.options.filter(opt => opt.long === o || opt.short === o)[0]; + if (option && option.autocomplete) { + optionsObj[o] = option.autocomplete; + } + else { + optionsObj[o] = {}; + } + }); + parent[current] = optionsObj; + } + } + + parent = parent[current]; + } + } +} + +export const autocomplete = new Autocomplete(); \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 9cd78fd318a..61618b43d19 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import config from './config'; import Command from './Command'; import appInsights from './appInsights'; import Utils from './Utils'; +import { autocomplete } from './autocomplete'; const packageJSON = require('../package.json'); const vorpal: Vorpal = require('./vorpal-init'), @@ -36,6 +37,20 @@ fs.realpath(__dirname, (err: NodeJS.ErrnoException, resolvedPath: string): void } }); + if (process.argv.indexOf('--completion:clink:generate') > -1) { + console.log(autocomplete.getClinkCompletion(vorpal)); + process.exit(); + } + if (process.argv.indexOf('--completion:sh:generate') > -1) { + autocomplete.generateShCompletion(vorpal); + process.exit(); + } + if (process.argv.indexOf('--completion:sh:setup') > -1) { + autocomplete.generateShCompletion(vorpal); + autocomplete.setupShCompletion(); + process.exit(); + } + vorpal .command('version', 'Shows the current version of the CLI') .action(function (this: CommandInstance, args: any, cb: () => void) { diff --git a/types/omelette.d.ts b/types/omelette.d.ts new file mode 100644 index 00000000000..e28e1a4f7e1 --- /dev/null +++ b/types/omelette.d.ts @@ -0,0 +1,12 @@ +interface Omelette { + init: () => never; + on: (event: string, eventHandler: (fragment: string, data: EventData) => void) => void; + setupShellInitFile: (initFile?: string) => void; +} + +interface EventData { + before: any; + fragment: number; + line: string; + reply: (words: any) => never; +} \ No newline at end of file diff --git a/types/vorpal.d.ts b/types/vorpal.d.ts index 054cffaaf1a..ad98eb24168 100644 --- a/types/vorpal.d.ts +++ b/types/vorpal.d.ts @@ -1,6 +1,7 @@ interface Vorpal { command: (command: string, description: string, autocomplete?: string[]) => VorpalCommand; _command: CurrentCommand; + commands: CommandInfo[]; delimiter: (delimiter: string) => Vorpal; exec: (command: string, callback?: () => void) => Promise; find: (command: string) => VorpalCommand; @@ -18,6 +19,7 @@ interface VorpalCommand { cancel: (handler: () => void) => VorpalCommand; help: (help: (args: any, cbOrLog: (message?: string) => void) => void) => VorpalCommand; helpInformation: () => string; + hidden: () => VorpalCommand; option: (name: string, description?: string, autocomplete?: string[]) => VorpalCommand; types: (types: { string?: string[], boolean?: string[] }) => VorpalCommand; validate: (validator: (args: any) => boolean | string) => VorpalCommand; @@ -31,4 +33,22 @@ interface CommandInstance { interface CurrentCommand { command: string; args: any; +} + +interface CommandInfo { + options: CommandOption[]; + _args: CommandArg[]; + _aliases: string[]; + _name: string; + _hidden: boolean; +} + +interface CommandOption { + autocomplete: string[]; + long: string; + short: string; +} + +interface CommandArg { + name: string; } \ No newline at end of file