@flowscripter/dynamic-cli-framework 1.1.6 → 1.2.1
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.
- package/.github/workflows/release-bun-library.yml +1 -0
- package/README/core-commands.md +40 -0
- package/README/core-service-providers.md +198 -0
- package/README/development.md +60 -0
- package/README/implementation-details.md +474 -0
- package/README/key-concepts.md +751 -0
- package/README.md +12 -1413
- package/bun.lock +22 -18
- package/index.ts +97 -1
- package/package.json +8 -7
- package/src/api/BaseCLIFeatureOptions.ts +10 -0
- package/src/api/CLIConfig.ts +5 -0
- package/src/api/RunResult.ts +16 -0
- package/src/api/argument/Argument.ts +24 -0
- package/src/api/service/ServiceProvider.ts +3 -3
- package/src/api/service/core/ArgumentPrompterService.ts +10 -0
- package/src/api/service/core/AsciiBannerGeneratorService.ts +44 -7
- package/src/api/service/core/CompletionService.ts +36 -0
- package/src/api/service/core/DataDumpGeneratorService.ts +27 -0
- package/src/api/service/core/ImagePrinterService.ts +26 -0
- package/src/api/service/core/KeyValueService.ts +22 -5
- package/src/api/service/core/PrinterService.ts +127 -1
- package/src/api/service/core/PrompterService.ts +49 -0
- package/src/api/service/core/SecretService.ts +27 -0
- package/src/api/service/core/ShutdownService.ts +17 -1
- package/src/api/service/core/Table.ts +85 -0
- package/src/api/service/core/TableGeneratorService.ts +38 -0
- package/src/api/service/core/TreePrinterService.ts +11 -0
- package/src/cli/BaseCLI.ts +116 -44
- package/src/cli/DefaultRuntimeCLI.ts +10 -18
- package/src/command/CompletionCommand.ts +179 -0
- package/src/command/MultiCommandCliHelpCommand.ts +18 -2
- package/src/command/SingleCommandCliHelpCommand.ts +10 -1
- package/src/launcher.ts +16 -58
- package/src/runtime/command/CommandValidator.ts +3 -3
- package/src/runtime/runner.ts +102 -30
- package/src/runtime/scanner.ts +18 -15
- package/src/runtime/values/argumentValueValidation.ts +49 -5
- package/src/runtime/values/globalCommandValuePopulation.ts +1 -1
- package/src/runtime/values/subCommandValuePopulation.ts +9 -8
- package/src/service/argumentPrompter/ArgumentPrompterServiceProvider.ts +34 -0
- package/src/service/argumentPrompter/DefaultArgumentPrompterService.ts +330 -0
- package/src/service/asciiBannerGenerator/AsciiBannerGeneratorServiceProvider.ts +1 -1
- package/src/service/asciiBannerGenerator/DefaultAsciiBannerGeneratorService.ts +254 -17
- package/src/service/banner/BannerServiceProvider.ts +2 -2
- package/src/service/chiselAsciiBannerGenerator/ChiselFontAsciiBannerGeneratorService.ts +85 -0
- package/src/service/chiselAsciiBannerGenerator/chisel.flf.json +3 -0
- package/src/service/completion/CompletionServiceProvider.ts +212 -0
- package/src/service/completion/DefaultCompletionService.ts +364 -0
- package/src/service/completion/shell/ShellHandler.ts +16 -0
- package/src/service/completion/shell/bash.ts +47 -0
- package/src/service/completion/shell/fish.ts +43 -0
- package/src/service/completion/shell/powershell.ts +66 -0
- package/src/service/completion/shell/zsh.ts +50 -0
- package/src/service/configuration/ConfigurationServiceProvider.ts +138 -42
- package/src/service/configuration/DefaultKeyValueService.ts +53 -7
- package/src/service/configuration/DefaultSecretService.ts +85 -0
- package/src/service/dataDumpGenerator/DataDumpGeneratorServiceProvider.ts +41 -0
- package/src/service/dataDumpGenerator/DefaultDataDumpGeneratorService.ts +80 -0
- package/src/service/imagePrinter/DefaultImagePrinterService.ts +23 -0
- package/src/service/imagePrinter/ImagePrinterServiceProvider.ts +32 -0
- package/src/service/prettyPrinter/PrettyPrinterServiceProvider.ts +1 -1
- package/src/service/printer/DefaultPrinterService.ts +147 -27
- package/src/service/printer/PrinterServiceProvider.ts +7 -7
- package/src/service/printer/command/NoHyperlinksCommand.ts +40 -0
- package/src/service/printer/terminal/Progress.ts +21 -4
- package/src/service/printer/terminal/Spinner.ts +32 -8
- package/src/service/printer/terminal/Theme.ts +1 -1
- package/src/service/prompter/DefaultPrompterService.ts +87 -0
- package/src/service/prompter/PrompterServiceProvider.ts +36 -0
- package/src/service/prompter/command/NoPromptCommand.ts +40 -0
- package/src/service/prompter/prompt/PromptContext.ts +47 -0
- package/src/service/prompter/prompt/promptAcknowledge.ts +28 -0
- package/src/service/prompter/prompt/promptMultiSelect.ts +99 -0
- package/src/service/prompter/prompt/promptOpenUrl.ts +97 -0
- package/src/service/prompter/prompt/promptSingleSelect.ts +102 -0
- package/src/service/prompter/prompt/promptText.ts +107 -0
- package/src/service/prompter/prompt/promptToggle.ts +28 -0
- package/src/service/shutdown/DefaultShutdownService.ts +13 -0
- package/src/service/shutdown/ShutdownServiceProvider.ts +28 -9
- package/src/service/shutdown/ShutdownState.ts +5 -0
- package/src/service/syntaxHighlighter/SyntaxHighlighterServiceProvider.ts +1 -1
- package/src/service/tableGenerator/DefaultTableGeneratorService.ts +415 -0
- package/src/service/tableGenerator/TableGeneratorServiceProvider.ts +43 -0
- package/src/service/treePrinter/DefaultTreePrinterService.ts +49 -0
- package/src/service/treePrinter/TreePrinterServiceProvider.ts +40 -0
- package/src/terminal/Ansi.ts +27 -0
- package/src/terminal/ImageRenderer.ts +199 -0
- package/src/terminal/KeyReader.ts +23 -0
- package/src/terminal/PngDecoder.ts +143 -0
- package/src/{service/printer/terminal → terminal}/Styler.ts +3 -0
- package/src/{service/printer/terminal → terminal}/Terminal.ts +1 -0
- package/src/terminal/TtyKeyReader.ts +83 -0
- package/src/terminal/TtyStyler.ts +78 -0
- package/src/{service/printer/terminal → terminal}/TtyTerminal.ts +9 -1
- package/src/terminal/supportsHyperlinks.ts +110 -0
- package/src/util/configHelper.ts +2 -2
- package/src/util/envVarHelper.ts +2 -2
- package/src/util/helpHelper.ts +66 -122
- package/src/util/logger.ts +16 -14
- package/src/util/runnerHelper.ts +1 -1
- package/tests/cli/BaseCLI.test.ts +50 -54
- package/tests/command/CompletionCommand.test.ts +68 -0
- package/tests/command/UsageCommand.test.ts +3 -2
- package/tests/command/VersionCommand.test.ts +3 -2
- package/tests/fixtures/Context.ts +9 -2
- package/tests/fixtures/util.ts +1 -1
- package/tests/runtime/parser.test.ts +9 -7
- package/tests/runtime/registry/DefaultServiceProviderRegistry.test.ts +3 -3
- package/tests/runtime/values/argumentValueValidation.test.ts +193 -0
- package/tests/service/argumentPrompter/ArgumentPrompterServiceProvider.test.ts +47 -0
- package/tests/service/argumentPrompter/DefaultArgumentPrompterService.test.ts +754 -0
- package/tests/service/asciiBannerGenerator/AsciiBannerGeneratorServiceProvider.test.ts +5 -4
- package/tests/service/asciiBannerGenerator/DefaultAsciiBannerGeneratorService.test.ts +163 -3
- package/tests/service/asciiBannerGenerator/__snapshots__/DefaultAsciiBannerGeneratorService.test.ts.snap +1 -1
- package/tests/service/banner/BannerServiceProvider.test.ts +8 -4
- package/tests/service/chiselAsciiBannerGenerator/ChiselFontAsciiBannerGeneratorService.test.ts +52 -0
- package/tests/service/completion/CompletionServiceProvider.test.ts +404 -0
- package/tests/service/completion/DefaultCompletionService.test.ts +278 -0
- package/tests/service/completion/shell/bash.test.ts +73 -0
- package/tests/service/completion/shell/fish.test.ts +62 -0
- package/tests/service/completion/shell/powershell.test.ts +59 -0
- package/tests/service/completion/shell/zsh.test.ts +60 -0
- package/tests/service/configuration/ConfigurationServiceProvider.test.ts +24 -12
- package/tests/service/configuration/DefaultKeyValueService.test.ts +118 -16
- package/tests/service/configuration/DefaultSecretService.test.ts +148 -0
- package/tests/service/configuration/command/ConfigCommand.test.ts +29 -0
- package/tests/service/configuration/command/DumpConfigCommand.test.ts +60 -0
- package/tests/service/dataDumpGenerator/DataDumpGeneratorServiceProvider.test.ts +57 -0
- package/tests/service/dataDumpGenerator/DefaultDataDumpGeneratorService.test.ts +217 -0
- package/tests/service/imagePrinter/DefaultImagePrinterService.test.ts +96 -0
- package/tests/service/imagePrinter/ImagePrinterServiceProvider.test.ts +17 -0
- package/tests/service/prettyPrinter/DefaultPrettyPrinterService.test.ts +2 -2
- package/tests/service/prettyPrinter/PrettyPrinterServiceProvider.test.ts +5 -4
- package/tests/service/printer/DefaultPrinterService.test.ts +233 -2
- package/tests/service/printer/PrinterServiceProvider.test.ts +6 -5
- package/tests/service/printer/command/DarkModeCommand.test.ts +83 -0
- package/tests/service/printer/command/LogLevelCommand.test.ts +7 -5
- package/tests/service/printer/command/NoColorCommand.test.ts +83 -0
- package/tests/service/printer/command/NoHyperlinksCommand.test.ts +89 -0
- package/tests/service/printer/terminal/Progress.test.ts +64 -4
- package/tests/service/printer/terminal/Spinner.test.ts +44 -3
- package/tests/service/prompter/DefaultPrompterService.test.ts +615 -0
- package/tests/service/prompter/PrompterServiceProvider.test.ts +47 -0
- package/tests/service/prompter/command/NoPromptCommand.test.ts +58 -0
- package/tests/service/shutdown/ShutdownServiceProvider.test.ts +4 -2
- package/tests/service/syntaxHighlighter/DefaultSyntaxHighlighterService.test.ts +3 -2
- package/tests/service/syntaxHighlighter/SyntaxHighlighterServiceProvider.test.ts +5 -4
- package/tests/service/tableGenerator/DefaultTableGeneratorService.test.ts +363 -0
- package/tests/service/tableGenerator/TableGeneratorServiceProvider.test.ts +47 -0
- package/tests/service/treePrinter/DefaultTreePrinterService.test.ts +144 -0
- package/tests/service/treePrinter/TreePrinterServiceProvider.test.ts +55 -0
- package/tests/terminal/ImageRenderer.test.ts +161 -0
- package/tests/terminal/PngDecoder.test.ts +223 -0
- package/tests/terminal/supportsHyperlinks.test.ts +149 -0
- package/tests/util/configHelper.test.ts +4 -4
- package/tests/util/logger.test.ts +23 -1
- package/tsconfig.json +31 -0
- package/src/service/printer/terminal/Ansi.ts +0 -10
- package/src/service/printer/terminal/TtyStyler.ts +0 -39
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import type { ServiceProvider } from "../../api/service/ServiceProvider.ts";
|
|
2
|
+
import type { ServiceInfo } from "../../api/service/ServiceProvider.ts";
|
|
3
|
+
import type CLIConfig from "../../api/CLIConfig.ts";
|
|
4
|
+
import type Context from "../../api/Context.ts";
|
|
5
|
+
import { COMPLETION_SERVICE_ID } from "../../api/service/core/CompletionService.ts";
|
|
6
|
+
import { ShellType } from "../../api/service/core/CompletionService.ts";
|
|
7
|
+
import type DefaultCompletionService from "./DefaultCompletionService.ts";
|
|
8
|
+
import { PROMPTER_SERVICE_ID } from "../../api/service/core/PrompterService.ts";
|
|
9
|
+
import { PromptType } from "../../api/service/core/PrompterService.ts";
|
|
10
|
+
import type PrompterService from "../../api/service/core/PrompterService.ts";
|
|
11
|
+
import { KEY_VALUE_SERVICE_ID } from "../../api/service/core/KeyValueService.ts";
|
|
12
|
+
import type KeyValueService from "../../api/service/core/KeyValueService.ts";
|
|
13
|
+
import {
|
|
14
|
+
Icon,
|
|
15
|
+
PRINTER_SERVICE_ID,
|
|
16
|
+
} from "../../api/service/core/PrinterService.ts";
|
|
17
|
+
import type PrinterService from "../../api/service/core/PrinterService.ts";
|
|
18
|
+
import getLogger from "../../util/logger.ts";
|
|
19
|
+
|
|
20
|
+
const logger = getLogger("CompletionServiceProvider");
|
|
21
|
+
|
|
22
|
+
export default class CompletionServiceProvider implements ServiceProvider {
|
|
23
|
+
readonly serviceId: string = COMPLETION_SERVICE_ID;
|
|
24
|
+
readonly servicePriority: number;
|
|
25
|
+
readonly #completionService: DefaultCompletionService;
|
|
26
|
+
#cliName = "";
|
|
27
|
+
|
|
28
|
+
public constructor(
|
|
29
|
+
servicePriority: number,
|
|
30
|
+
completionService: DefaultCompletionService,
|
|
31
|
+
) {
|
|
32
|
+
this.servicePriority = servicePriority;
|
|
33
|
+
this.#completionService = completionService;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
public getServiceInfo(cliConfig: CLIConfig): Promise<ServiceInfo> {
|
|
37
|
+
this.#cliName = cliConfig.name;
|
|
38
|
+
return Promise.resolve({
|
|
39
|
+
service: this.#completionService,
|
|
40
|
+
commands: [],
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
public async initService(context: Context): Promise<void> {
|
|
45
|
+
if (!context.doesServiceExist(PROMPTER_SERVICE_ID)) {
|
|
46
|
+
logger.debug(() => "PrompterService not available, skipping auto-prompt");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (!context.doesServiceExist(KEY_VALUE_SERVICE_ID)) {
|
|
50
|
+
logger.debug(
|
|
51
|
+
() => "KeyValueService not available, skipping auto-prompt",
|
|
52
|
+
);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const keyValueService = context.getServiceById(
|
|
57
|
+
KEY_VALUE_SERVICE_ID,
|
|
58
|
+
) as KeyValueService;
|
|
59
|
+
if (await keyValueService.hasKey("completion-status")) {
|
|
60
|
+
const status = await keyValueService.getKey("completion-status");
|
|
61
|
+
if (status === "installed" || status === "declined") {
|
|
62
|
+
logger.debug(
|
|
63
|
+
() => `Completion status is '${status}', skipping auto-prompt`,
|
|
64
|
+
);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const prompterService = context.getServiceById(
|
|
70
|
+
PROMPTER_SERVICE_ID,
|
|
71
|
+
) as PrompterService;
|
|
72
|
+
if (!prompterService.promptEnabled) {
|
|
73
|
+
logger.debug(() => "Prompting is disabled, skipping auto-prompt");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const detectedShells = await this.#detectAvailableShells();
|
|
78
|
+
if (detectedShells.length === 0) {
|
|
79
|
+
logger.debug(
|
|
80
|
+
() => "No supported shells detected, skipping auto-prompt",
|
|
81
|
+
);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let enableResult;
|
|
86
|
+
try {
|
|
87
|
+
enableResult = await prompterService.prompt({
|
|
88
|
+
name: "enable-completion",
|
|
89
|
+
promptText: "Would you like to enable autocompletion?",
|
|
90
|
+
description:
|
|
91
|
+
"This will set up your terminal so that pressing TAB while typing commands will show possible options and autocomplete arguments.\n(Enabling autocompletion will modify configuration files in your home directory.)",
|
|
92
|
+
type: PromptType.TOGGLE,
|
|
93
|
+
options: [
|
|
94
|
+
{ displayValue: "Yes", returnedValue: true },
|
|
95
|
+
{ displayValue: "No", returnedValue: false },
|
|
96
|
+
],
|
|
97
|
+
});
|
|
98
|
+
} catch {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (enableResult.value !== true) {
|
|
103
|
+
await keyValueService.setKey("completion-status", "declined");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let shellType: ShellType;
|
|
108
|
+
|
|
109
|
+
if (detectedShells.length === 1) {
|
|
110
|
+
shellType = detectedShells[0]!;
|
|
111
|
+
} else {
|
|
112
|
+
const shellOptions = detectedShells.map((shell) => ({
|
|
113
|
+
displayValue: shell,
|
|
114
|
+
returnedValue: shell,
|
|
115
|
+
}));
|
|
116
|
+
|
|
117
|
+
let shellResult;
|
|
118
|
+
try {
|
|
119
|
+
shellResult = await prompterService.prompt({
|
|
120
|
+
name: "shell-type",
|
|
121
|
+
promptText: "Which shell would you like to configure?",
|
|
122
|
+
type: PromptType.SINGLE_SELECT,
|
|
123
|
+
defaultOption: shellOptions[0],
|
|
124
|
+
options: shellOptions,
|
|
125
|
+
});
|
|
126
|
+
} catch {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
shellType = shellResult.value as ShellType;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
await this.#installCompletion(context, shellType);
|
|
135
|
+
await keyValueService.setKey("completion-status", "installed");
|
|
136
|
+
} catch (error) {
|
|
137
|
+
logger.error(
|
|
138
|
+
"Failed to install completion: %s",
|
|
139
|
+
(error as Error).message,
|
|
140
|
+
);
|
|
141
|
+
if (context.doesServiceExist(PRINTER_SERVICE_ID)) {
|
|
142
|
+
const printerService = context.getServiceById(
|
|
143
|
+
PRINTER_SERVICE_ID,
|
|
144
|
+
) as PrinterService;
|
|
145
|
+
await printerService.error(
|
|
146
|
+
`Failed to install completion: ${(error as Error).message}`,
|
|
147
|
+
Icon.FAILURE,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async #detectAvailableShells(): Promise<ShellType[]> {
|
|
154
|
+
const available: ShellType[] = [];
|
|
155
|
+
for (const shell of Object.values(ShellType)) {
|
|
156
|
+
if (await this.#completionService.validateShellEnvironment(shell)) {
|
|
157
|
+
available.push(shell);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return available;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async #installCompletion(
|
|
164
|
+
context: Context,
|
|
165
|
+
shellType: ShellType,
|
|
166
|
+
): Promise<void> {
|
|
167
|
+
const configPath = this.#completionService.getDefaultConfigPath(shellType);
|
|
168
|
+
const bootstrapScript = this.#completionService.getBootstrapScript(
|
|
169
|
+
shellType,
|
|
170
|
+
this.#cliName,
|
|
171
|
+
);
|
|
172
|
+
const beginMarker = `# BEGIN ${this.#cliName} completion`;
|
|
173
|
+
const endMarker = `# END ${this.#cliName} completion`;
|
|
174
|
+
const block = `${beginMarker}\n${bootstrapScript}\n${endMarker}`;
|
|
175
|
+
|
|
176
|
+
let existingContent = "";
|
|
177
|
+
try {
|
|
178
|
+
existingContent = await Bun.file(configPath).text();
|
|
179
|
+
} catch {
|
|
180
|
+
// File doesn't exist yet
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let newContent: string;
|
|
184
|
+
const beginIdx = existingContent.indexOf(beginMarker);
|
|
185
|
+
const endIdx = existingContent.indexOf(endMarker);
|
|
186
|
+
if (beginIdx !== -1 && endIdx !== -1) {
|
|
187
|
+
newContent = existingContent.substring(0, beginIdx) +
|
|
188
|
+
block +
|
|
189
|
+
existingContent.substring(endIdx + endMarker.length);
|
|
190
|
+
} else {
|
|
191
|
+
const separator = existingContent.length > 0 &&
|
|
192
|
+
!existingContent.endsWith("\n")
|
|
193
|
+
? "\n\n"
|
|
194
|
+
: existingContent.length > 0
|
|
195
|
+
? "\n"
|
|
196
|
+
: "";
|
|
197
|
+
newContent = existingContent + separator + block + "\n";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
await Bun.write(configPath, newContent);
|
|
201
|
+
|
|
202
|
+
if (context.doesServiceExist(PRINTER_SERVICE_ID)) {
|
|
203
|
+
const printerService = context.getServiceById(
|
|
204
|
+
PRINTER_SERVICE_ID,
|
|
205
|
+
) as PrinterService;
|
|
206
|
+
await printerService.info(
|
|
207
|
+
`Shell completion installed for ${shellType}. Restart your shell or run 'source ${configPath}' to activate.`,
|
|
208
|
+
Icon.SUCCESS,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import type CompletionService from "../../api/service/core/CompletionService.ts";
|
|
2
|
+
import {
|
|
3
|
+
type CompletionItem,
|
|
4
|
+
ShellType,
|
|
5
|
+
} from "../../api/service/core/CompletionService.ts";
|
|
6
|
+
import type CommandRegistry from "../../runtime/registry/CommandRegistry.ts";
|
|
7
|
+
import type ShellHandler from "./shell/ShellHandler.ts";
|
|
8
|
+
import BashShellHandler from "./shell/bash.ts";
|
|
9
|
+
import ZshShellHandler from "./shell/zsh.ts";
|
|
10
|
+
import FishShellHandler from "./shell/fish.ts";
|
|
11
|
+
import PowerShellShellHandler from "./shell/powershell.ts";
|
|
12
|
+
import type SubCommand from "../../api/command/SubCommand.ts";
|
|
13
|
+
import type Option from "../../api/argument/Option.ts";
|
|
14
|
+
|
|
15
|
+
export default class DefaultCompletionService implements CompletionService {
|
|
16
|
+
#commandRegistry: CommandRegistry | undefined;
|
|
17
|
+
|
|
18
|
+
readonly #shellHandlers: Map<ShellType, ShellHandler> = new Map([
|
|
19
|
+
[ShellType.BASH, new BashShellHandler()],
|
|
20
|
+
[ShellType.ZSH, new ZshShellHandler()],
|
|
21
|
+
[ShellType.FISH, new FishShellHandler()],
|
|
22
|
+
[ShellType.POWERSHELL, new PowerShellShellHandler()],
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
setCommandRegistry(registry: CommandRegistry): void {
|
|
26
|
+
this.#commandRegistry = registry;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
getBootstrapScript(shellType: ShellType, cliName: string): string {
|
|
30
|
+
return this.#getHandler(shellType).getBootstrapScript(cliName);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
getDefaultConfigPath(shellType: ShellType): string {
|
|
34
|
+
return this.#getHandler(shellType).getDefaultConfigPath();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
validateShellEnvironment(shellType: ShellType): Promise<boolean> {
|
|
38
|
+
return this.#getHandler(shellType).validateEnvironment();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
formatCompletions(
|
|
42
|
+
shellType: ShellType,
|
|
43
|
+
completions: ReadonlyArray<CompletionItem>,
|
|
44
|
+
): string {
|
|
45
|
+
return this.#getHandler(shellType).formatCompletions(completions);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
parseCompletionContext(
|
|
49
|
+
shell: ShellType,
|
|
50
|
+
args: ReadonlyArray<string>,
|
|
51
|
+
): { line: string; cursorPosition: number } {
|
|
52
|
+
return this.#getHandler(shell).parseCompletionContext(args);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
generateCompletions(
|
|
56
|
+
_shellType: ShellType,
|
|
57
|
+
line: string,
|
|
58
|
+
cursorPosition: number,
|
|
59
|
+
): Promise<ReadonlyArray<CompletionItem>> {
|
|
60
|
+
if (!this.#commandRegistry) {
|
|
61
|
+
return Promise.resolve([]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const beforeCursor = line.substring(0, cursorPosition);
|
|
65
|
+
const words = this.#tokenize(beforeCursor);
|
|
66
|
+
|
|
67
|
+
// Remove CLI name (first word)
|
|
68
|
+
const relevantWords = words.slice(1);
|
|
69
|
+
const currentWord = relevantWords[relevantWords.length - 1] ?? "";
|
|
70
|
+
const previousWords = relevantWords.slice(0, -1);
|
|
71
|
+
const endsWithSpace = beforeCursor.endsWith(" ");
|
|
72
|
+
|
|
73
|
+
if (
|
|
74
|
+
relevantWords.length === 0 ||
|
|
75
|
+
(relevantWords.length === 1 && !endsWithSpace)
|
|
76
|
+
) {
|
|
77
|
+
return Promise.resolve(this.#completeCommandName(currentWord));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (endsWithSpace) {
|
|
81
|
+
const commandWord = relevantWords[0]!;
|
|
82
|
+
return Promise.resolve(
|
|
83
|
+
this.#completeAfterCommand(commandWord, relevantWords.slice(1), ""),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const commandWord = previousWords[0];
|
|
88
|
+
if (commandWord) {
|
|
89
|
+
return Promise.resolve(
|
|
90
|
+
this.#completeAfterCommand(
|
|
91
|
+
commandWord,
|
|
92
|
+
previousWords.slice(1),
|
|
93
|
+
currentWord,
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return Promise.resolve(this.#completeCommandName(currentWord));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
#completeAfterCommand(
|
|
102
|
+
commandWord: string,
|
|
103
|
+
intermediateWords: ReadonlyArray<string>,
|
|
104
|
+
currentWord: string,
|
|
105
|
+
): ReadonlyArray<CompletionItem> {
|
|
106
|
+
// Check for group:member pattern
|
|
107
|
+
if (commandWord.includes(":")) {
|
|
108
|
+
const result = this.#commandRegistry!
|
|
109
|
+
.getGroupCommandAndMemberSubCommandByJoinedName(commandWord);
|
|
110
|
+
if (result) {
|
|
111
|
+
return this.#completeSubCommandArgs(
|
|
112
|
+
result.command,
|
|
113
|
+
intermediateWords,
|
|
114
|
+
currentWord,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Check if partial group:member (e.g. "completions:" completing member)
|
|
120
|
+
if (currentWord.includes(":")) {
|
|
121
|
+
const colonIdx = currentWord.indexOf(":");
|
|
122
|
+
const groupPart = currentWord.substring(0, colonIdx);
|
|
123
|
+
const groupCommand = this.#commandRegistry!.getGroupCommandByName(
|
|
124
|
+
groupPart,
|
|
125
|
+
);
|
|
126
|
+
if (groupCommand) {
|
|
127
|
+
return groupCommand.memberSubCommands
|
|
128
|
+
.filter((sc) => `${groupPart}:${sc.name}`.startsWith(currentWord))
|
|
129
|
+
.map((sc) => ({
|
|
130
|
+
value: `${groupPart}:${sc.name}`,
|
|
131
|
+
description: sc.description,
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Check sub-command
|
|
137
|
+
const subCommand = this.#commandRegistry!.getSubCommandByName(commandWord);
|
|
138
|
+
if (subCommand) {
|
|
139
|
+
return this.#completeSubCommandArgs(
|
|
140
|
+
subCommand,
|
|
141
|
+
intermediateWords,
|
|
142
|
+
currentWord,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Check group command (suggest members)
|
|
147
|
+
const groupCommand = this.#commandRegistry!.getGroupCommandByName(
|
|
148
|
+
commandWord,
|
|
149
|
+
);
|
|
150
|
+
if (groupCommand) {
|
|
151
|
+
return groupCommand.memberSubCommands
|
|
152
|
+
.filter((sc) => sc.name.startsWith(currentWord))
|
|
153
|
+
.map((sc) => ({
|
|
154
|
+
value: `${commandWord}:${sc.name}`,
|
|
155
|
+
description: sc.description,
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return this.#completeCommandName(currentWord);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#completeCommandName(prefix: string): ReadonlyArray<CompletionItem> {
|
|
163
|
+
const completions: CompletionItem[] = [];
|
|
164
|
+
|
|
165
|
+
// Sub-commands
|
|
166
|
+
const subCommands = this.#commandRegistry!.getSubCommands();
|
|
167
|
+
for (const cmd of subCommands) {
|
|
168
|
+
if (cmd.name.startsWith(prefix)) {
|
|
169
|
+
completions.push({ value: cmd.name, description: cmd.description });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Group:member joined names
|
|
174
|
+
const groupAndMembers = this.#commandRegistry!
|
|
175
|
+
.getGroupAndMemberCommandsByJoinedName();
|
|
176
|
+
for (const [joinedName, entry] of groupAndMembers) {
|
|
177
|
+
if (joinedName.startsWith(prefix)) {
|
|
178
|
+
completions.push({
|
|
179
|
+
value: joinedName,
|
|
180
|
+
description: entry.command.description,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Group command names (to suggest further typing "group:")
|
|
186
|
+
const groupCommands = this.#commandRegistry!.getGroupCommands();
|
|
187
|
+
for (const cmd of groupCommands) {
|
|
188
|
+
if (cmd.name.startsWith(prefix) && !prefix.includes(":")) {
|
|
189
|
+
completions.push({
|
|
190
|
+
value: cmd.name,
|
|
191
|
+
description: cmd.description,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Global commands (with -- prefix)
|
|
197
|
+
const globalCommands = this.#commandRegistry!.getGlobalCommands();
|
|
198
|
+
for (const cmd of globalCommands) {
|
|
199
|
+
const prefixed = `--${cmd.name}`;
|
|
200
|
+
if (prefixed.startsWith(prefix)) {
|
|
201
|
+
completions.push({ value: prefixed, description: cmd.description });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Global modifier commands (with -- prefix)
|
|
206
|
+
const modifierCommands = this.#commandRegistry!
|
|
207
|
+
.getGlobalModifierCommands();
|
|
208
|
+
for (const cmd of modifierCommands) {
|
|
209
|
+
const prefixed = `--${cmd.name}`;
|
|
210
|
+
if (prefixed.startsWith(prefix)) {
|
|
211
|
+
completions.push({ value: prefixed, description: cmd.description });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return completions;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
#completeSubCommandArgs(
|
|
219
|
+
subCommand: SubCommand,
|
|
220
|
+
previousArgs: ReadonlyArray<string>,
|
|
221
|
+
currentWord: string,
|
|
222
|
+
): ReadonlyArray<CompletionItem> {
|
|
223
|
+
const completions: CompletionItem[] = [];
|
|
224
|
+
|
|
225
|
+
// If current word starts with --, complete option names
|
|
226
|
+
if (currentWord.startsWith("--")) {
|
|
227
|
+
const prefix = currentWord.substring(2);
|
|
228
|
+
for (const opt of subCommand.options) {
|
|
229
|
+
if ("name" in opt && (opt as Option).name.startsWith(prefix)) {
|
|
230
|
+
completions.push({
|
|
231
|
+
value: `--${(opt as Option).name}`,
|
|
232
|
+
description: (opt as Option).description,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return completions;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// If current word starts with -, complete short aliases
|
|
240
|
+
if (currentWord.startsWith("-") && currentWord.length <= 2) {
|
|
241
|
+
for (const opt of subCommand.options) {
|
|
242
|
+
if ("shortAlias" in opt && (opt as Option).shortAlias) {
|
|
243
|
+
const alias = `-${(opt as Option).shortAlias}`;
|
|
244
|
+
if (alias.startsWith(currentWord)) {
|
|
245
|
+
completions.push({
|
|
246
|
+
value: alias,
|
|
247
|
+
description: (opt as Option).description,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return completions;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Check if previous word was an option expecting a value
|
|
256
|
+
const lastPrevWord = previousArgs[previousArgs.length - 1];
|
|
257
|
+
if (lastPrevWord && lastPrevWord.startsWith("--")) {
|
|
258
|
+
const optName = lastPrevWord.substring(2);
|
|
259
|
+
const option = subCommand.options.find(
|
|
260
|
+
(o) => "name" in o && (o as Option).name === optName,
|
|
261
|
+
) as Option | undefined;
|
|
262
|
+
if (option && option.allowableValues) {
|
|
263
|
+
return option.allowableValues
|
|
264
|
+
.filter((v) => String(v).startsWith(currentWord))
|
|
265
|
+
.map((v) => ({ value: String(v) }));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Suggest options that haven't been used
|
|
270
|
+
for (const opt of subCommand.options) {
|
|
271
|
+
if ("name" in opt) {
|
|
272
|
+
const optName = `--${(opt as Option).name}`;
|
|
273
|
+
if (
|
|
274
|
+
!previousArgs.includes(optName) && optName.startsWith(currentWord)
|
|
275
|
+
) {
|
|
276
|
+
completions.push({
|
|
277
|
+
value: optName,
|
|
278
|
+
description: (opt as Option).description,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Suggest positional allowable values
|
|
285
|
+
const positionalIndex = this.#countPositionalArgs(
|
|
286
|
+
previousArgs,
|
|
287
|
+
subCommand,
|
|
288
|
+
);
|
|
289
|
+
if (positionalIndex < subCommand.positionals.length) {
|
|
290
|
+
const positional = subCommand.positionals[positionalIndex]!;
|
|
291
|
+
if (positional.allowableValues) {
|
|
292
|
+
for (const v of positional.allowableValues) {
|
|
293
|
+
if (String(v).startsWith(currentWord)) {
|
|
294
|
+
completions.push({ value: String(v) });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return completions;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
#countPositionalArgs(
|
|
304
|
+
args: ReadonlyArray<string>,
|
|
305
|
+
subCommand: SubCommand,
|
|
306
|
+
): number {
|
|
307
|
+
let count = 0;
|
|
308
|
+
let i = 0;
|
|
309
|
+
while (i < args.length) {
|
|
310
|
+
const arg = args[i]!;
|
|
311
|
+
if (arg.startsWith("-")) {
|
|
312
|
+
// Skip option and its value
|
|
313
|
+
const optName = arg.startsWith("--") ? arg.substring(2) : undefined;
|
|
314
|
+
const option = optName
|
|
315
|
+
? subCommand.options.find(
|
|
316
|
+
(o) => "name" in o && (o as Option).name === optName,
|
|
317
|
+
)
|
|
318
|
+
: undefined;
|
|
319
|
+
if (option && option.type !== undefined) {
|
|
320
|
+
i++; // skip the option value
|
|
321
|
+
}
|
|
322
|
+
} else {
|
|
323
|
+
count++;
|
|
324
|
+
}
|
|
325
|
+
i++;
|
|
326
|
+
}
|
|
327
|
+
return count;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
#tokenize(input: string): string[] {
|
|
331
|
+
const words: string[] = [];
|
|
332
|
+
let current = "";
|
|
333
|
+
let inSingleQuote = false;
|
|
334
|
+
let inDoubleQuote = false;
|
|
335
|
+
|
|
336
|
+
for (let i = 0; i < input.length; i++) {
|
|
337
|
+
const ch = input[i]!;
|
|
338
|
+
if (ch === "'" && !inDoubleQuote) {
|
|
339
|
+
inSingleQuote = !inSingleQuote;
|
|
340
|
+
} else if (ch === '"' && !inSingleQuote) {
|
|
341
|
+
inDoubleQuote = !inDoubleQuote;
|
|
342
|
+
} else if (ch === " " && !inSingleQuote && !inDoubleQuote) {
|
|
343
|
+
if (current.length > 0) {
|
|
344
|
+
words.push(current);
|
|
345
|
+
current = "";
|
|
346
|
+
}
|
|
347
|
+
} else {
|
|
348
|
+
current += ch;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (current.length > 0) {
|
|
352
|
+
words.push(current);
|
|
353
|
+
}
|
|
354
|
+
return words;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
#getHandler(shellType: ShellType): ShellHandler {
|
|
358
|
+
const handler = this.#shellHandlers.get(shellType);
|
|
359
|
+
if (!handler) {
|
|
360
|
+
throw new Error(`Unsupported shell type: ${shellType}`);
|
|
361
|
+
}
|
|
362
|
+
return handler;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { CompletionItem } from "../../../api/service/core/CompletionService.ts";
|
|
2
|
+
|
|
3
|
+
export interface CompletionContext {
|
|
4
|
+
readonly line: string;
|
|
5
|
+
readonly cursorPosition: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export default interface ShellHandler {
|
|
9
|
+
getDefaultConfigPath(): string;
|
|
10
|
+
getBootstrapScript(cliName: string): string;
|
|
11
|
+
validateEnvironment(): Promise<boolean>;
|
|
12
|
+
formatCompletions(completions: ReadonlyArray<CompletionItem>): string;
|
|
13
|
+
parseCompletionContext(
|
|
14
|
+
args: ReadonlyArray<string>,
|
|
15
|
+
): CompletionContext;
|
|
16
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { CompletionItem } from "../../../api/service/core/CompletionService.ts";
|
|
5
|
+
import type ShellHandler from "./ShellHandler.ts";
|
|
6
|
+
import type { CompletionContext } from "./ShellHandler.ts";
|
|
7
|
+
|
|
8
|
+
// Reference: https://www.gnu.org/software/bash/manual/html_node/Programmable-Completion.html
|
|
9
|
+
|
|
10
|
+
export default class BashShellHandler implements ShellHandler {
|
|
11
|
+
getDefaultConfigPath(): string {
|
|
12
|
+
return join(homedir(), ".bashrc");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
getBootstrapScript(cliName: string): string {
|
|
16
|
+
return [
|
|
17
|
+
`_${cliName}_completions() {`,
|
|
18
|
+
` local IFS=$'\\n'`,
|
|
19
|
+
` COMPREPLY=( $(${cliName} completions:complete bash "\${COMP_LINE}" "\${COMP_POINT}") )`,
|
|
20
|
+
`}`,
|
|
21
|
+
`complete -F _${cliName}_completions ${cliName}`,
|
|
22
|
+
].join("\n");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
validateEnvironment(): Promise<boolean> {
|
|
26
|
+
const shell = process.env.SHELL || "";
|
|
27
|
+
if (shell.endsWith("/bash")) {
|
|
28
|
+
return Promise.resolve(true);
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const result = Bun.spawnSync(["bash", "--version"]);
|
|
32
|
+
return Promise.resolve(result.exitCode === 0);
|
|
33
|
+
} catch {
|
|
34
|
+
return Promise.resolve(false);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
formatCompletions(completions: ReadonlyArray<CompletionItem>): string {
|
|
39
|
+
return completions.map((c) => c.value).join("\n");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
parseCompletionContext(args: ReadonlyArray<string>): CompletionContext {
|
|
43
|
+
const line = args[0] || "";
|
|
44
|
+
const cursorPosition = parseInt(args[1] || "0", 10) || line.length;
|
|
45
|
+
return { line, cursorPosition };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { CompletionItem } from "../../../api/service/core/CompletionService.ts";
|
|
5
|
+
import type ShellHandler from "./ShellHandler.ts";
|
|
6
|
+
import type { CompletionContext } from "./ShellHandler.ts";
|
|
7
|
+
|
|
8
|
+
// Reference: https://fishshell.com/docs/current/completions.html
|
|
9
|
+
|
|
10
|
+
export default class FishShellHandler implements ShellHandler {
|
|
11
|
+
getDefaultConfigPath(): string {
|
|
12
|
+
return join(homedir(), ".config", "fish", "config.fish");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
getBootstrapScript(cliName: string): string {
|
|
16
|
+
return `complete -c ${cliName} -f -a '(${cliName} completions:complete fish (commandline -cp) (commandline -C))'`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
validateEnvironment(): Promise<boolean> {
|
|
20
|
+
const shell = process.env.SHELL || "";
|
|
21
|
+
if (shell.endsWith("/fish")) {
|
|
22
|
+
return Promise.resolve(true);
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const result = Bun.spawnSync(["fish", "--version"]);
|
|
26
|
+
return Promise.resolve(result.exitCode === 0);
|
|
27
|
+
} catch {
|
|
28
|
+
return Promise.resolve(false);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
formatCompletions(completions: ReadonlyArray<CompletionItem>): string {
|
|
33
|
+
return completions
|
|
34
|
+
.map((c) => c.description ? `${c.value}\t${c.description}` : c.value)
|
|
35
|
+
.join("\n");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
parseCompletionContext(args: ReadonlyArray<string>): CompletionContext {
|
|
39
|
+
const line = args[0] || "";
|
|
40
|
+
const cursorPosition = parseInt(args[1] || "0", 10) || line.length;
|
|
41
|
+
return { line, cursorPosition };
|
|
42
|
+
}
|
|
43
|
+
}
|