Skip to content

Commit 786b75e

Browse files
Added support for autocomplete in Zsh and clink solving #142 and #190
1 parent 38ad176 commit 786b75e

10 files changed

Lines changed: 344 additions & 1 deletion

File tree

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,6 @@ dist
6161
.DS_Store
6262
coverage
6363
# generated docs
64-
docs/manual/site
64+
docs/manual/site
65+
# generated commands info
66+
commands.json

.npmignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ src
99
types
1010
.gitignore
1111
CONTRIBUTING.md
12+
commands.json
1213
tsconfig.json
1314
*.map
1415
*.spec.js
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Command completion
2+
3+
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.
4+
5+
## Immersive mode
6+
7+
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.
8+
9+
## Non-immersive mode
10+
11+
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.
12+
13+
### Clink (cmder)
14+
15+
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/).
16+
17+
#### Enable Clink completion
18+
19+
To enable completion:
20+
21+
1. Start your shell
22+
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.
23+
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
24+
1. Restart your shell
25+
26+
You should now be able to complete your input, eg. typing `o365 s<tab>` will complete it to `o365 spo` and typing `o365 spo <tab><tab>` will list all SharePoint Online commands available in Office 365 CLI. To see the options available for the current command, type `-<tab><tab>`, for example `o365 spo app list -<tab><tab>` will list all options available for the `o365 spo app list` command.
27+
28+
#### Disable Clink completion
29+
30+
To disable completion, delete the `o365.lua` file you generated previously and restart your shell.
31+
32+
#### Update Clink completion
33+
34+
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.
35+
36+
### Zsh, Bash and Fish
37+
38+
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.
39+
40+
#### Enable sh completion
41+
42+
To enable completion:
43+
44+
1. Start your shell
45+
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).
46+
1. Restart your shell
47+
48+
You should now be able to complete your input, eg. typing `o365 s<tab>` will complete it to `o365 spo` and typing `o365 spo <tab><tab>` will list all SharePoint Online commands available in Office 365 CLI. To see the options available for the command, type `-<tab><tab>`, for example `o365 spo app list -<tab><tab>` 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.
49+
50+
#### Disable sh completion
51+
52+
To disable completion, edit your shell's profile file (for Zsh `~/.zshrc`) and remove the following lines:
53+
54+
```sh
55+
# begin o365 completion
56+
. <(o365 --completion)
57+
# end o365 completion
58+
```
59+
60+
Save the profile file and restart the shell for the changes to take effect.
61+
62+
#### Update sh completion
63+
64+
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.

docs/manual/mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ pages:
5959
- Concepts:
6060
- 'Persisting connection': 'concepts/persisting-connection.md'
6161
- 'Authorization and access tokens': 'concepts/authorization-tokens.md'
62+
- 'Command completion': 'concepts/completion.md'
6263
- About:
6364
- 'Why this CLI': 'about/why-cli.md'
6465
- 'Comparison to SharePoint PowerShell': 'about/comparison-powershell.md'

npm-shrinkwrap.json

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"dependencies": {
4646
"applicationinsights": "^1.0.1",
4747
"easy-table": "^1.1.0",
48+
"omelette": "^0.4.5",
4849
"request-promise-native": "^1.0.5",
4950
"vorpal": "^1.12.0"
5051
},

src/autocomplete.ts

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
const omelette: (template: string) => Omelette = require('omelette');
2+
import * as os from 'os';
3+
import * as fs from 'fs';
4+
import * as path from 'path';
5+
6+
class Autocomplete {
7+
private static autocompleteFilePath: string = path.join(__dirname, `..${path.sep}commands.json`);
8+
private omelette: Omelette;
9+
private commands: any = {};
10+
11+
constructor() {
12+
this.init();
13+
}
14+
15+
private init(): void {
16+
if (fs.existsSync(Autocomplete.autocompleteFilePath)) {
17+
try {
18+
const data: string = fs.readFileSync(Autocomplete.autocompleteFilePath, 'utf-8');
19+
this.commands = JSON.parse(data);
20+
}
21+
catch { }
22+
}
23+
24+
const _this = this;
25+
26+
function handleAutocomplete(this: any, fragment: string, data: any): void {
27+
let replies: Object | string[] = {};
28+
let allWords: string[] = [];
29+
30+
if (data.fragment === 1) {
31+
replies = Object.keys(_this.commands);
32+
}
33+
else {
34+
allWords = data.line.split(/\s+/).slice(1, -1);
35+
// build array of words to use as a path to retrieve completion
36+
// options from the commands tree
37+
const words: string[] = allWords
38+
.filter((e: string, i: number): boolean => {
39+
if (e.indexOf('-') !== 0) {
40+
// if the word is not an option check if it's not
41+
// option's value, eg. --output json, in which case
42+
// the suggestion should be command options
43+
return i === 0 || allWords[i - 1].indexOf('-') !== 0;
44+
}
45+
else {
46+
// remove all options but last one
47+
return i === allWords.length - 1;
48+
}
49+
});
50+
let accessor: Function = new Function('_', "return _['" + (words.join("']['")) + "']");
51+
52+
replies = accessor(_this.commands);
53+
// if the last word is an option without autocomplete
54+
// suggest other options from the same command
55+
if (words[words.length - 1].indexOf('-') === 0 &&
56+
!Array.isArray(replies)) {
57+
accessor = new Function('_', "return _['" + (words.filter(w => w.indexOf('-') !== 0).join("']['")) + "']");
58+
replies = accessor(_this.commands);
59+
60+
if (!Array.isArray(replies)) {
61+
replies = Object.keys(replies);
62+
}
63+
}
64+
}
65+
66+
if (!Array.isArray(replies)) {
67+
replies = Object.keys(replies);
68+
}
69+
70+
// remove options that already have been used
71+
replies = (replies as string[]).filter(r => r.indexOf('-') !== 0 || allWords.indexOf(r) === -1);
72+
73+
this.reply(replies);
74+
}
75+
76+
this.omelette = omelette('o365|office365');
77+
this.omelette.on('complete', handleAutocomplete);
78+
this.omelette.init();
79+
}
80+
81+
public generateShCompletion(vorpal: Vorpal): void {
82+
const commandsInfo: any = this.getCommandsInfo(vorpal);
83+
fs.writeFileSync(Autocomplete.autocompleteFilePath, JSON.stringify(commandsInfo));
84+
}
85+
86+
public setupShCompletion(): void {
87+
this.omelette.setupShellInitFile();
88+
}
89+
90+
public getClinkCompletion(vorpal: Vorpal): string {
91+
const cmd: any = this.getCommandsInfo(vorpal);
92+
const lua: string[] = ['local parser = clink.arg.new_parser'];
93+
94+
this.buildClinkForBranch(cmd, lua, 'o365');
95+
96+
lua.push(
97+
'',
98+
'clink.arg.register_parser("o365", o365_parser)',
99+
'clink.arg.register_parser("office365", o365_parser)'
100+
);
101+
102+
return lua.join(os.EOL);
103+
}
104+
105+
private buildClinkForBranch(branch: any, lua: string[], luaFunctionName: string): void {
106+
if (!Array.isArray(branch)) {
107+
const keys: string[] = Object.keys(branch);
108+
109+
if (keys.length > 0) {
110+
keys.forEach(k => {
111+
if (Object.keys(branch[k]).length > 0) {
112+
this.buildClinkForBranch(branch[k], lua, this.getLuaFunctionName(`${luaFunctionName}_${k}`));
113+
}
114+
});
115+
}
116+
}
117+
118+
lua.push(
119+
'',
120+
`local ${luaFunctionName}_parser = parser({`
121+
);
122+
123+
let printingArgs: boolean = false;
124+
125+
if (Array.isArray(branch)) {
126+
if (branch.find(c => c.indexOf('-') === 0)) {
127+
printingArgs = true;
128+
lua.push(`},${branch.map(c => `"${c}"`).join(',')}`);
129+
}
130+
else {
131+
branch.sort().forEach((c, i) => {
132+
const separator = i < branch.length - 1 ? ',' : '';
133+
lua.push(` "${c}"${separator}`);
134+
});
135+
}
136+
}
137+
else {
138+
const keys = Object.keys(branch);
139+
if (keys.find(c => c.indexOf('-') === 0)) {
140+
printingArgs = true;
141+
const tmp: string[] = [];
142+
keys.sort().forEach((k, i) => {
143+
if (Object.keys(branch[k]).length > 0) {
144+
tmp.push(`"${k}"..${this.getLuaFunctionName(`${luaFunctionName}_${k}_parser`)}`);
145+
}
146+
else {
147+
tmp.push(`"${k}"`);
148+
}
149+
});
150+
151+
lua.push(`},${tmp.join(',')}`);
152+
}
153+
else {
154+
keys.sort().forEach((k, i) => {
155+
const separator = i < keys.length - 1 ? ',' : '';
156+
if (Object.keys(branch[k]).length > 0) {
157+
lua.push(` "${k}"..${this.getLuaFunctionName(`${luaFunctionName}_${k}_parser`)}${separator}`);
158+
}
159+
else {
160+
lua.push(` "${k}"${separator}`);
161+
}
162+
});
163+
}
164+
}
165+
166+
lua.push(`${printingArgs ? '' : '}'})`);
167+
}
168+
169+
private getLuaFunctionName(functionName: string): string {
170+
return functionName.replace(/-/g, '_');
171+
}
172+
173+
private getCommandsInfo(vorpal: Vorpal): any {
174+
const commandsInfo: any = {};
175+
const commands: CommandInfo[] = vorpal.commands;
176+
const visibleCommands: CommandInfo[] = commands.filter(c => !c._hidden);
177+
visibleCommands.forEach(c => {
178+
Autocomplete.processCommand(c._name, c, commandsInfo);
179+
c._aliases.forEach(a => Autocomplete.processCommand(a, c, commandsInfo));
180+
});
181+
182+
return commandsInfo;
183+
}
184+
185+
private static processCommand(commandName: string, commandInfo: CommandInfo, autocomplete: any) {
186+
const chunks: string[] = commandName.split(' ');
187+
let parent: any = autocomplete;
188+
for (let i: number = 0; i < chunks.length; i++) {
189+
const current: any = chunks[i];
190+
if (current === 'exit' || current === 'quit') {
191+
continue;
192+
}
193+
194+
if (!parent[current]) {
195+
if (i < chunks.length - 1) {
196+
parent[current] = {};
197+
}
198+
else {
199+
// last chunk, add options
200+
const optionsArr: string[] = commandInfo.options.map(o => o.short)
201+
.concat(commandInfo.options.map(o => o.long)).filter(o => o != null);
202+
optionsArr.push('--help');
203+
const optionsObj: any = {};
204+
optionsArr.forEach(o => {
205+
const option: CommandOption = commandInfo.options.filter(opt => opt.long === o || opt.short === o)[0];
206+
if (option && option.autocomplete) {
207+
optionsObj[o] = option.autocomplete;
208+
}
209+
else {
210+
optionsObj[o] = {};
211+
}
212+
});
213+
parent[current] = optionsObj;
214+
}
215+
}
216+
217+
parent = parent[current];
218+
}
219+
}
220+
}
221+
222+
export const autocomplete = new Autocomplete();

src/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import config from './config';
66
import Command from './Command';
77
import appInsights from './appInsights';
88
import Utils from './Utils';
9+
import { autocomplete } from './autocomplete';
910

1011
const packageJSON = require('../package.json');
1112
const vorpal: Vorpal = require('./vorpal-init'),
@@ -36,6 +37,20 @@ fs.realpath(__dirname, (err: NodeJS.ErrnoException, resolvedPath: string): void
3637
}
3738
});
3839

40+
if (process.argv.indexOf('--completion:clink:generate') > -1) {
41+
console.log(autocomplete.getClinkCompletion(vorpal));
42+
process.exit();
43+
}
44+
if (process.argv.indexOf('--completion:sh:generate') > -1) {
45+
autocomplete.generateShCompletion(vorpal);
46+
process.exit();
47+
}
48+
if (process.argv.indexOf('--completion:sh:setup') > -1) {
49+
autocomplete.generateShCompletion(vorpal);
50+
autocomplete.setupShCompletion();
51+
process.exit();
52+
}
53+
3954
vorpal
4055
.command('version', 'Shows the current version of the CLI')
4156
.action(function (this: CommandInstance, args: any, cb: () => void) {

types/omelette.d.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
interface Omelette {
2+
init: () => never;
3+
on: (event: string, eventHandler: (fragment: string, data: EventData) => void) => void;
4+
setupShellInitFile: (initFile?: string) => void;
5+
}
6+
7+
interface EventData {
8+
before: any;
9+
fragment: number;
10+
line: string;
11+
reply: (words: any) => never;
12+
}

0 commit comments

Comments
 (0)