@widgetic/cli 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +122 -0
  2. package/README.md +184 -2
  3. package/bin/widgetic.js +5 -0
  4. package/dist/cli.js +138 -0
  5. package/dist/commands/auth.js +269 -0
  6. package/dist/commands/call.js +97 -0
  7. package/dist/commands/completion.js +150 -0
  8. package/dist/commands/doctor.js +293 -0
  9. package/dist/commands/schema.js +216 -0
  10. package/dist/commands/update.js +159 -0
  11. package/dist/commands/verify.js +64 -0
  12. package/dist/config.js +98 -0
  13. package/dist/confirm.js +15 -0
  14. package/dist/constants.js +57 -0
  15. package/dist/contract-note.js +42 -0
  16. package/dist/credentials.js +111 -0
  17. package/dist/environment.js +150 -0
  18. package/dist/errors.js +67 -0
  19. package/dist/help.js +174 -0
  20. package/dist/http.js +122 -0
  21. package/dist/input.js +37 -0
  22. package/dist/main.js +30 -0
  23. package/dist/manifest/load.js +39 -0
  24. package/dist/manifest/types.js +14 -0
  25. package/dist/mcp/plan.js +32 -0
  26. package/dist/mcp/register.js +112 -0
  27. package/dist/mcp/session.js +186 -0
  28. package/dist/mcp/wait.js +68 -0
  29. package/dist/options.js +228 -0
  30. package/dist/output.js +90 -0
  31. package/dist/overlay.js +54 -0
  32. package/dist/patterns.js +11 -0
  33. package/dist/rest/execute.js +67 -0
  34. package/dist/rest/mime.js +37 -0
  35. package/dist/rest/plan.js +185 -0
  36. package/dist/rest/register.js +144 -0
  37. package/dist/retry.js +49 -0
  38. package/dist/secrets.js +149 -0
  39. package/dist/state.js +37 -0
  40. package/dist/update/installation.js +83 -0
  41. package/dist/update/lookup.js +128 -0
  42. package/dist/update/offer.js +165 -0
  43. package/dist/update/perform.js +33 -0
  44. package/dist/update/registry.js +68 -0
  45. package/dist/update/version.js +39 -0
  46. package/dist/validate.js +93 -0
  47. package/dist/version.js +59 -0
  48. package/generated/contracts.json +1 -0
  49. package/generated/mcp-manifest.json +1 -0
  50. package/generated/rest-manifest.json +1 -0
  51. package/package.json +56 -5
@@ -0,0 +1,269 @@
1
+ import { resolveSettings, timeoutMilliseconds } from "../config.js";
2
+ import { API_KEY_SHAPE } from "../constants.js";
3
+ import { CredentialStore, maskKey, noApiKey, resolveApiKey } from "../credentials.js";
4
+ import { readAll } from "../environment.js";
5
+ import { CliError, EXIT_CODES, usageError } from "../errors.js";
6
+ import { addBasicOptions, readCommon } from "../options.js";
7
+ import { note, printResult, writeStdout } from "../output.js";
8
+ import { retryNotifier } from "../retry.js";
9
+ import { verifyApiKey } from "./verify.js";
10
+ /**
11
+ * `widgetic auth login | logout | status`: the one place the CLI's key is saved, forgotten and looked at. A key is read
12
+ * from a hidden prompt on a terminal or from standard input (`--with-key`), never from an argument (an argument is in
13
+ * the shell history and in `ps`), checked with the server before it is saved, and written to a private file. The
14
+ * environment variable `WIDGETIC_API_KEY` needs none of this and always wins over a saved key.
15
+ */
16
+ const ENVIRONMENT_KEY_NOTE = 'note: WIDGETIC_API_KEY is set in this environment, and the CLI uses it before any saved key.';
17
+ /** A key is one printable word: anything else would be refused by the server and could not be put in a header. */
18
+ function looksLikeAWord(key) {
19
+ return /^[\x21-\x7e]+$/.test(key);
20
+ }
21
+ async function readKey(environment, input) {
22
+ if (input.withKey) {
23
+ const text = await readAll(environment.stdin);
24
+ const key = (text.split(/\r?\n/)[0] ?? '').trim();
25
+ if (key === '') {
26
+ throw usageError('--with-key: standard input holds no key.', 'Pipe it in: echo "$WIDGETIC_API_KEY" | widgetic auth login --with-key');
27
+ }
28
+ return key;
29
+ }
30
+ if (input.json) {
31
+ throw usageError('--json never asks anything, so the key cannot be typed.', 'Pipe it in: echo "$WIDGETIC_API_KEY" | widgetic auth login --with-key --json');
32
+ }
33
+ if (environment.promptSecret === undefined) {
34
+ throw usageError('There is no terminal to ask for the key on.', 'Pipe it in with --with-key, or set WIDGETIC_API_KEY and skip logging in.');
35
+ }
36
+ const typed = (await environment.promptSecret('API key (it is not shown): ')).trim();
37
+ if (typed === '')
38
+ throw usageError('No key was typed.');
39
+ return typed;
40
+ }
41
+ /** What a failed save means to the person: where it was meant to go, and the way around it. */
42
+ function notSaved(path, error) {
43
+ return new CliError({
44
+ code: 'CREDENTIALS_NOT_SAVED',
45
+ message: `The key could not be saved in ${path}: ${error instanceof Error ? error.message : String(error)}`,
46
+ exitCode: EXIT_CODES.failure,
47
+ hint: 'Set WIDGETIC_API_KEY instead, or choose another folder with WIDGETIC_CONFIG_DIR.',
48
+ cause: error
49
+ });
50
+ }
51
+ function describeCheck(check) {
52
+ const scopes = check.scopes.length === 0 ? 'no scopes listed' : `scopes: ${check.scopes.join(', ')}`;
53
+ return `${check.tokenType ?? 'key'}; ${scopes}`;
54
+ }
55
+ async function runLogin(environment, node) {
56
+ const common = readCommon(node);
57
+ const options = node.opts();
58
+ const settings = resolveSettings(environment, { profile: common.profile });
59
+ const key = await readKey(environment, { withKey: options.withKey === true, json: common.json });
60
+ if (!looksLikeAWord(key)) {
61
+ throw usageError('That is not an API key: it holds spaces or characters a key never has.');
62
+ }
63
+ if (!common.json && !API_KEY_SHAPE.test(key)) {
64
+ note(environment, 'note: that does not look like a Widgetic API key (they start with wgt_usr_, wgt_prt_ or wdg_org_); checking it anyway.');
65
+ }
66
+ // `--no-verify` is `verify: false`; without it the server is asked, and a key it refuses is never saved.
67
+ const check = options.verify === false
68
+ ? undefined
69
+ : await verifyApiKey(environment, settings, key, {
70
+ timeoutMilliseconds: timeoutMilliseconds(common.timeout),
71
+ onRetry: retryNotifier(environment, common.json)
72
+ });
73
+ const store = new CredentialStore(environment, settings.configDir);
74
+ try {
75
+ await store.save(settings.profile, key);
76
+ }
77
+ catch (error) {
78
+ if (error instanceof CliError)
79
+ throw error;
80
+ throw notSaved(store.path, error);
81
+ }
82
+ if (common.json) {
83
+ printResult(environment, {
84
+ profile: settings.profile,
85
+ saved: true,
86
+ key: maskKey(key),
87
+ file: store.path,
88
+ verified: check !== undefined,
89
+ ...(check
90
+ ? {
91
+ tokenType: check.tokenType,
92
+ scopes: check.scopes,
93
+ userId: check.userId,
94
+ expiresAt: check.expiresAt
95
+ }
96
+ : {})
97
+ }, true);
98
+ return;
99
+ }
100
+ const lines = [`Saved the key ${maskKey(key)} for the profile "${settings.profile}".`];
101
+ if (check)
102
+ lines.push(` The server accepted it (${describeCheck(check)}).`);
103
+ else
104
+ lines.push(' It was not checked with the server (--no-verify).');
105
+ lines.push(` File: ${store.path} (only you can read it).`);
106
+ writeStdout(environment, lines.join('\n'));
107
+ if (environment.env.WIDGETIC_API_KEY?.trim())
108
+ note(environment, ENVIRONMENT_KEY_NOTE);
109
+ }
110
+ async function runLogout(environment, node) {
111
+ const common = readCommon(node);
112
+ const settings = resolveSettings(environment, { profile: common.profile });
113
+ const store = new CredentialStore(environment, settings.configDir);
114
+ const everything = node.opts().all === true;
115
+ const names = everything ? Object.keys((await store.read()).profiles) : [settings.profile];
116
+ const removed = [];
117
+ for (const name of names) {
118
+ if (await store.remove(name))
119
+ removed.push(name);
120
+ }
121
+ if (common.json) {
122
+ printResult(environment, { removed }, true);
123
+ return;
124
+ }
125
+ if (removed.length === 0) {
126
+ writeStdout(environment, everything
127
+ ? 'There were no saved keys.'
128
+ : `There was no saved key for the profile "${settings.profile}".`);
129
+ }
130
+ else {
131
+ writeStdout(environment, removed.length === 1 && !everything
132
+ ? `Removed the saved key of the profile "${settings.profile}".`
133
+ : `Removed the saved keys of: ${removed.join(', ')}.`);
134
+ }
135
+ if (environment.env.WIDGETIC_API_KEY?.trim()) {
136
+ note(environment, 'note: WIDGETIC_API_KEY is still set in this environment, and the CLI keeps using it.');
137
+ }
138
+ }
139
+ async function verdictOn(environment, settings, found, timeout, json) {
140
+ try {
141
+ const check = await verifyApiKey(environment, settings, found.apiKey, {
142
+ timeoutMilliseconds: timeoutMilliseconds(timeout),
143
+ keySource: found.source,
144
+ onRetry: retryNotifier(environment, json)
145
+ });
146
+ return {
147
+ verdict: {
148
+ accepted: true,
149
+ checked: true,
150
+ tokenType: check.tokenType,
151
+ scopes: check.scopes,
152
+ userId: check.userId,
153
+ expiresAt: check.expiresAt
154
+ },
155
+ check,
156
+ failure: undefined
157
+ };
158
+ }
159
+ catch (error) {
160
+ if (!(error instanceof CliError))
161
+ throw error;
162
+ const refused = error.exitCode === EXIT_CODES.auth;
163
+ return {
164
+ verdict: {
165
+ accepted: false,
166
+ // A refusal is an answer; a server that could not be reached has not said anything.
167
+ checked: refused,
168
+ ...(error.status === undefined ? {} : { status: error.status }),
169
+ code: error.code,
170
+ message: error.message
171
+ },
172
+ check: undefined,
173
+ failure: error
174
+ };
175
+ }
176
+ }
177
+ async function runStatus(environment, node) {
178
+ const common = readCommon(node);
179
+ const settings = resolveSettings(environment, { profile: common.profile });
180
+ const store = new CredentialStore(environment, settings.configDir);
181
+ const found = await resolveApiKey(environment, store, settings.profile);
182
+ // The saved profiles are listed even when the key in use is the environment's; a damaged file is that key's business only.
183
+ let profiles = [];
184
+ let savedAt;
185
+ try {
186
+ const file = await store.read();
187
+ profiles = Object.keys(file.profiles).sort();
188
+ savedAt = file.profiles[settings.profile]?.savedAt;
189
+ }
190
+ catch (error) {
191
+ if (found?.source !== 'environment')
192
+ throw error;
193
+ }
194
+ let verdict;
195
+ let check;
196
+ let failure;
197
+ if (found) {
198
+ ({ verdict, check, failure } = await verdictOn(environment, settings, found, common.timeout, common.json));
199
+ }
200
+ else {
201
+ failure = noApiKey(settings);
202
+ }
203
+ const report = {
204
+ profile: settings.profile,
205
+ authenticated: verdict?.accepted === true,
206
+ source: found?.source ?? null,
207
+ key: found ? maskKey(found.apiKey) : null,
208
+ ...(found?.source === 'file' && savedAt ? { savedAt } : {}),
209
+ file: store.path,
210
+ profiles,
211
+ server: verdict ?? null
212
+ };
213
+ if (common.json) {
214
+ printResult(environment, report, true);
215
+ }
216
+ else {
217
+ const lines = [`Profile: ${settings.profile}`];
218
+ if (!found) {
219
+ lines.push('Key: none');
220
+ }
221
+ else if (found.source === 'environment') {
222
+ lines.push(`Key: ${maskKey(found.apiKey)}, from WIDGETIC_API_KEY`);
223
+ }
224
+ else {
225
+ lines.push(`Key: ${maskKey(found.apiKey)}, saved in ${store.path}${savedAt ? ` on ${savedAt}` : ''}`);
226
+ }
227
+ if (verdict?.accepted && check) {
228
+ lines.push(`Server: accepted (${describeCheck(check)}) at ${settings.apiUrl}`);
229
+ }
230
+ else if (verdict) {
231
+ lines.push(verdict.checked
232
+ ? `Server: did not accept it${verdict.status ? ` (HTTP ${verdict.status})` : ''}`
233
+ : `Server: not reached (${verdict.message ?? 'no answer'})`);
234
+ }
235
+ if (profiles.length > 0)
236
+ lines.push(`Saved profiles: ${profiles.join(', ')}`);
237
+ writeStdout(environment, lines.join('\n'));
238
+ }
239
+ if (failure)
240
+ throw failure;
241
+ }
242
+ /** Adds `auth` and its three commands under the root. */
243
+ export function registerAuthCommands(root, environment) {
244
+ const auth = root.command('auth').description('Save, forget and check the API key the CLI uses.');
245
+ const login = auth
246
+ .command('login')
247
+ .description('Check an API key with the server and save it for this profile.\n\nThe key is asked for on the terminal without showing it, or read from standard input with --with-key. It is never taken from an argument. Setting WIDGETIC_API_KEY instead needs no login.')
248
+ .option('--with-key', 'Read the key from standard input instead of asking for it.')
249
+ .option('--no-verify', 'Save the key without asking the server whether it works.');
250
+ addBasicOptions(login, { server: true });
251
+ login.action(async () => {
252
+ await runLogin(environment, login);
253
+ });
254
+ const logout = auth
255
+ .command('logout')
256
+ .description('Forget the saved key of this profile (or of every profile with --all).')
257
+ .option('--all', 'Forget the saved keys of every profile.');
258
+ addBasicOptions(logout, { server: true });
259
+ logout.action(async () => {
260
+ await runLogout(environment, logout);
261
+ });
262
+ const status = auth
263
+ .command('status')
264
+ .description('Show which key a command would use, where it comes from, and whether the server accepts it.\n\nExit code 0 when it is accepted, 3 when there is no key or the server refuses it, 6 when the server cannot be reached.');
265
+ addBasicOptions(status, { server: true });
266
+ status.action(async () => {
267
+ await runStatus(environment, status);
268
+ });
269
+ }
@@ -0,0 +1,97 @@
1
+ import { usageError } from "../errors.js";
2
+ import { registerMcpCommand } from "../mcp/register.js";
3
+ import { findMcpCommand, findRestCommand, unknownOperation } from "./schema.js";
4
+ /**
5
+ * `widgetic api call <operationId>` and `widgetic mcp call <tool>`: the way in by the contract's own name.
6
+ *
7
+ * An operationId (`createUserApiKey`) is what the OpenAPI description, the SDK methods and the documentation call an
8
+ * operation; a tool name (`create_widget`) is what the MCP server calls a tool. Both run the very command that is
9
+ * generated for it, with the flags of that command, so there is one behaviour and one place to read it:
10
+ *
11
+ * widgetic api call getWidget --id 42 --json is widgetic api widgets get-widget --id 42 --json
12
+ *
13
+ * The one thing `mcp call` adds is a way to call a tool this CLI has no command for: a server that is newer than the
14
+ * CLI offers tools the CLI was not built with, and `mcp call new_tool --input '{…}'` reaches one without an update.
15
+ * Such a call is sent as it is (the server checks the arguments) and asks before it runs, because the CLI cannot tell
16
+ * what the tool does.
17
+ */
18
+ const argumentsOf = (schemaCommand) => `The flags and values of the command it runs (\`${schemaCommand}\` lists them).`;
19
+ /**
20
+ * A flag where the name belongs (`call --json getWidget`). The flags are the ones of the command that runs, so they
21
+ * come after its name, and the message says so instead of calling the flag an unknown operation.
22
+ */
23
+ function nameFirst(word, form) {
24
+ if (!word.startsWith('-'))
25
+ return;
26
+ throw usageError(`Name what to run before its flags: "${word}" is a flag, and the flags are the ones of the command that runs.`, `Write \`widgetic ${form} ${word}\`.`);
27
+ }
28
+ /** Adds `call` under `api`. The groups and commands must already be registered: the command to run is found among them. */
29
+ export function registerApiCall(api, manifest) {
30
+ api
31
+ .command('call')
32
+ .summary('Run an operation by its operationId')
33
+ .description('Run one operation by the operationId the contract gives it (createUserApiKey), with the flags of its own command.\n\nIt is the same as `widgetic api <group> <command>`, found by the name the OpenAPI description, the SDK and the documentation use. The operationId comes first; everything after it is the flags of that command.\n\nThe operations that `--help` does not list (those of the public embed runtime) are reached this way too.')
34
+ .argument('<operationId>', 'The operationId of the operation (createUserApiKey)')
35
+ .argument('[arguments...]', argumentsOf('widgetic schema api <operationId>'))
36
+ .allowUnknownOption()
37
+ .passThroughOptions()
38
+ .action(async (operationId, rest) => {
39
+ nameFirst(operationId, 'api call <operationId>');
40
+ const match = findRestCommand(manifest, [operationId]);
41
+ if (!match)
42
+ throw unknownOperation(manifest, [operationId]);
43
+ const node = api.commands
44
+ .find((group) => group.name() === match.group.name)
45
+ ?.commands.find((command) => command.name() === match.command.name);
46
+ if (!node)
47
+ throw new Error(`The command of ${operationId} was not registered.`);
48
+ await node.parseAsync(rest, { from: 'user' });
49
+ });
50
+ }
51
+ /** What the CLI knows of a tool it has no command for: nothing, which is said in its help and in the question it asks. */
52
+ function unknownTool(tool) {
53
+ return {
54
+ name: tool,
55
+ tool,
56
+ title: `Call the tool ${tool}`,
57
+ description: `${tool} is not a tool this CLI was built with, so its arguments are sent exactly as --input gives them and the server checks them. The CLI cannot tell what it changes, so it asks before it runs.`,
58
+ scopes: [],
59
+ scopeGroup: 'write',
60
+ readOnly: false,
61
+ destructive: false,
62
+ idempotent: false,
63
+ confirm: true,
64
+ inputSchema: { type: 'object' },
65
+ flags: [],
66
+ inputOnly: []
67
+ };
68
+ }
69
+ /** The names a tool can have: what the server accepts in `tools/call`. */
70
+ const TOOL_NAME = /^[A-Za-z][A-Za-z0-9_.-]{0,127}$/;
71
+ /** Adds `call` under `mcp`. The tools must already be registered. */
72
+ export function registerMcpCall(mcp, manifest, run) {
73
+ mcp
74
+ .command('call')
75
+ .summary('Call a tool by its name')
76
+ .description('Call one tool by the name the server gives it (create_widget), with the flags of its own command.\n\nIt is the same as `widgetic mcp <tool>`. A tool that this CLI has no command for (the server is newer than the CLI) is called with --input only, and asks before it runs, since nothing is known about what it does.')
77
+ .argument('<tool>', 'The tool, as a command (create-widget) or as the server names it (create_widget)')
78
+ .argument('[arguments...]', argumentsOf('widgetic schema mcp <tool>'))
79
+ .allowUnknownOption()
80
+ .passThroughOptions()
81
+ .action(async (tool, rest) => {
82
+ nameFirst(tool, 'mcp call <tool>');
83
+ const known = findMcpCommand(manifest.commands, tool);
84
+ if (known) {
85
+ const node = mcp.commands.find((command) => command.name() === known.name);
86
+ if (!node)
87
+ throw new Error(`The command of ${known.tool} was not registered.`);
88
+ await node.parseAsync(rest, { from: 'user' });
89
+ return;
90
+ }
91
+ if (tool === 'call' || tool === 'help' || !TOOL_NAME.test(tool)) {
92
+ throw usageError(`"${tool}" is not the name of a tool.`, 'Run `widgetic mcp --help` to see the tools.');
93
+ }
94
+ const node = registerMcpCommand(mcp, unknownTool(tool), run);
95
+ await node.parseAsync(rest, { from: 'user' });
96
+ });
97
+ }
@@ -0,0 +1,150 @@
1
+ import { usageError } from "../errors.js";
2
+ import { writeStdout } from "../output.js";
3
+ /**
4
+ * Tab completion for bash, zsh and fish.
5
+ *
6
+ * The scripts are three lines each and know nothing about the commands: they hand the text typed so far to the hidden
7
+ * `widgetic __complete --line "<text>"`, which builds the program the way `run` would for those words and answers with
8
+ * the subcommands, flags and choices that fit. So completion is always the commands this build has (generated from the
9
+ * contracts), and nothing is kept in step by hand. A completion that fails prints nothing: a shell prompt must never
10
+ * show a stack.
11
+ */
12
+ export const SHELLS = ['bash', 'zsh', 'fish'];
13
+ const SCRIPTS = {
14
+ bash: `# widgetic completion for bash. Add this line to ~/.bashrc:
15
+ # eval "$(widgetic completion bash)"
16
+ _widgetic_complete() {
17
+ local IFS=$'\\n'
18
+ COMPREPLY=($(widgetic __complete --line "\${COMP_LINE:0:COMP_POINT}" 2>/dev/null))
19
+ }
20
+ complete -o default -F _widgetic_complete widgetic
21
+ `,
22
+ zsh: `#compdef widgetic
23
+ # widgetic completion for zsh. Add this line to ~/.zshrc, after compinit:
24
+ # eval "$(widgetic completion zsh)"
25
+ _widgetic() {
26
+ local -a candidates
27
+ candidates=( \${(f)"$(widgetic __complete --line "$LBUFFER" 2>/dev/null)"} )
28
+ if (( \${#candidates} )); then
29
+ compadd -a candidates
30
+ else
31
+ _files
32
+ fi
33
+ }
34
+ compdef _widgetic widgetic
35
+ `,
36
+ fish: `# widgetic completion for fish. Save it as ~/.config/fish/completions/widgetic.fish:
37
+ # widgetic completion fish > ~/.config/fish/completions/widgetic.fish
38
+ complete -c widgetic -f -a '(widgetic __complete --line (commandline -cp) 2>/dev/null)'
39
+ `
40
+ };
41
+ /** The script that completes `widgetic` in this shell. */
42
+ export function completionScript(shell) {
43
+ return SCRIPTS[shell];
44
+ }
45
+ /**
46
+ * The words of a command line as the shell would split them (quotes and backslashes honoured), and the word being
47
+ * typed: the last one when the text does not end in a space, else an empty one (the next word is about to start).
48
+ */
49
+ export function splitLine(line) {
50
+ const words = [];
51
+ let word = '';
52
+ let inWord = false;
53
+ let quote;
54
+ for (let index = 0; index < line.length; index += 1) {
55
+ const character = line[index];
56
+ if (quote !== undefined) {
57
+ if (character === quote)
58
+ quote = undefined;
59
+ else
60
+ word += character;
61
+ continue;
62
+ }
63
+ if (character === '"' || character === "'") {
64
+ quote = character;
65
+ inWord = true;
66
+ }
67
+ else if (character === '\\' && index + 1 < line.length) {
68
+ index += 1;
69
+ word += line[index];
70
+ inWord = true;
71
+ }
72
+ else if (/\s/.test(character)) {
73
+ if (inWord) {
74
+ words.push(word);
75
+ word = '';
76
+ inWord = false;
77
+ }
78
+ }
79
+ else {
80
+ word += character;
81
+ inWord = true;
82
+ }
83
+ }
84
+ return inWord ? { words, current: word } : { words, current: '' };
85
+ }
86
+ /** What can follow `words` after the program name, given the start of the next word. */
87
+ export function completionsFor(build, words, current) {
88
+ const program = build(words);
89
+ let command = program;
90
+ // The option typed last that takes a value, so that value (not a subcommand) is what is being completed.
91
+ let waitingFor;
92
+ for (const word of words) {
93
+ if (waitingFor !== undefined) {
94
+ waitingFor = undefined;
95
+ continue;
96
+ }
97
+ if (word.startsWith('-')) {
98
+ const name = word.includes('=') ? word.slice(0, word.indexOf('=')) : word;
99
+ const option = command.options.find((candidate) => candidate.long === name || candidate.short === name);
100
+ if (option && !word.includes('=') && (option.required || option.optional))
101
+ waitingFor = option;
102
+ continue;
103
+ }
104
+ const next = command.commands.find((candidate) => candidate.name() === word || candidate.aliases().includes(word));
105
+ if (next)
106
+ command = next;
107
+ }
108
+ const starting = (candidates) => [...new Set(candidates)].filter((candidate) => candidate.startsWith(current)).sort();
109
+ if (waitingFor !== undefined)
110
+ return starting(waitingFor.argChoices ?? []);
111
+ const help = command.createHelp();
112
+ if (current.startsWith('-')) {
113
+ return starting(help
114
+ .visibleOptions(command)
115
+ .map((option) => option.long ?? option.short)
116
+ .filter((name) => name !== undefined));
117
+ }
118
+ return starting(help.visibleCommands(command).flatMap((candidate) => [candidate.name(), ...candidate.aliases()]));
119
+ }
120
+ /** Adds `completion <shell>` and the hidden `__complete` under the root. */
121
+ export function registerCompletionCommands(root, environment, build) {
122
+ root
123
+ .command('completion')
124
+ // The line the list of commands of `widgetic --help` shows; the shell lines are the help of `completion` itself.
125
+ .summary('Print the script that completes commands and flags with the Tab key.')
126
+ .description('Print the script that completes widgetic commands and flags with the Tab key.\n\nbash: eval "$(widgetic completion bash)" in ~/.bashrc\nzsh: eval "$(widgetic completion zsh)" in ~/.zshrc, after compinit\nfish: widgetic completion fish > ~/.config/fish/completions/widgetic.fish')
127
+ .argument('<shell>', `One of: ${SHELLS.join(', ')}`)
128
+ .action((shell) => {
129
+ if (!SHELLS.includes(shell)) {
130
+ throw usageError(`There is no completion for "${shell}".`, `Use one of: ${SHELLS.join(', ')}.`);
131
+ }
132
+ writeStdout(environment, completionScript(shell).trimEnd());
133
+ });
134
+ root
135
+ .command('__complete', { hidden: true })
136
+ .description('Answers the completion scripts: the words that can follow the text typed so far.')
137
+ .requiredOption('--line <text>', 'The text typed so far, up to the cursor')
138
+ .action((options) => {
139
+ try {
140
+ const { words, current } = splitLine(options.line);
141
+ // The first word is the program as typed (`widgetic`, `./bin/widgetic.js`); what follows is the command line.
142
+ const candidates = completionsFor(build, words.slice(1), current);
143
+ if (candidates.length > 0)
144
+ writeStdout(environment, candidates.join('\n'));
145
+ }
146
+ catch {
147
+ // A prompt shows nothing rather than a failure.
148
+ }
149
+ });
150
+ }