@pablozaiden/terminatui 0.1.0
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/.devcontainer/devcontainer.json +19 -0
- package/.devcontainer/install-prerequisites.sh +49 -0
- package/.github/workflows/copilot-setup-steps.yml +32 -0
- package/.github/workflows/pull-request.yml +27 -0
- package/.github/workflows/release-npm-package.yml +78 -0
- package/LICENSE +21 -0
- package/README.md +524 -0
- package/examples/tui-app/commands/greet.ts +75 -0
- package/examples/tui-app/commands/index.ts +3 -0
- package/examples/tui-app/commands/math.ts +114 -0
- package/examples/tui-app/commands/status.ts +75 -0
- package/examples/tui-app/index.ts +34 -0
- package/guides/01-hello-world.md +96 -0
- package/guides/02-adding-options.md +103 -0
- package/guides/03-multiple-commands.md +163 -0
- package/guides/04-subcommands.md +206 -0
- package/guides/05-interactive-tui.md +194 -0
- package/guides/06-config-validation.md +264 -0
- package/guides/07-async-cancellation.md +388 -0
- package/guides/08-complete-application.md +673 -0
- package/guides/README.md +74 -0
- package/package.json +32 -0
- package/src/__tests__/application.test.ts +425 -0
- package/src/__tests__/buildCliCommand.test.ts +125 -0
- package/src/__tests__/builtins.test.ts +133 -0
- package/src/__tests__/colors.test.ts +127 -0
- package/src/__tests__/command.test.ts +157 -0
- package/src/__tests__/commandClass.test.ts +130 -0
- package/src/__tests__/context.test.ts +97 -0
- package/src/__tests__/help.test.ts +412 -0
- package/src/__tests__/parser.test.ts +268 -0
- package/src/__tests__/registry.test.ts +195 -0
- package/src/__tests__/registryNew.test.ts +160 -0
- package/src/__tests__/schemaToFields.test.ts +176 -0
- package/src/__tests__/table.test.ts +146 -0
- package/src/__tests__/tui.test.ts +26 -0
- package/src/builtins/help.ts +85 -0
- package/src/builtins/index.ts +4 -0
- package/src/builtins/settings.ts +106 -0
- package/src/builtins/version.ts +72 -0
- package/src/cli/help.ts +174 -0
- package/src/cli/index.ts +3 -0
- package/src/cli/output/colors.ts +74 -0
- package/src/cli/output/index.ts +2 -0
- package/src/cli/output/table.ts +141 -0
- package/src/cli/parser.ts +241 -0
- package/src/commands/help.ts +50 -0
- package/src/commands/index.ts +1 -0
- package/src/components/index.ts +147 -0
- package/src/core/application.ts +461 -0
- package/src/core/command.ts +269 -0
- package/src/core/context.ts +112 -0
- package/src/core/help.ts +214 -0
- package/src/core/index.ts +15 -0
- package/src/core/logger.ts +164 -0
- package/src/core/registry.ts +140 -0
- package/src/hooks/index.ts +131 -0
- package/src/index.ts +137 -0
- package/src/registry/commandRegistry.ts +77 -0
- package/src/registry/index.ts +1 -0
- package/src/tui/TuiApp.tsx +582 -0
- package/src/tui/TuiApplication.tsx +230 -0
- package/src/tui/app.ts +29 -0
- package/src/tui/components/ActionButton.tsx +36 -0
- package/src/tui/components/CliModal.tsx +81 -0
- package/src/tui/components/CommandSelector.tsx +159 -0
- package/src/tui/components/ConfigForm.tsx +148 -0
- package/src/tui/components/EditorModal.tsx +177 -0
- package/src/tui/components/FieldRow.tsx +30 -0
- package/src/tui/components/Header.tsx +31 -0
- package/src/tui/components/JsonHighlight.tsx +128 -0
- package/src/tui/components/LogsPanel.tsx +86 -0
- package/src/tui/components/ResultsPanel.tsx +93 -0
- package/src/tui/components/StatusBar.tsx +59 -0
- package/src/tui/components/index.ts +13 -0
- package/src/tui/components/types.ts +30 -0
- package/src/tui/context/KeyboardContext.tsx +118 -0
- package/src/tui/context/index.ts +7 -0
- package/src/tui/hooks/index.ts +35 -0
- package/src/tui/hooks/useClipboard.ts +66 -0
- package/src/tui/hooks/useCommandExecutor.ts +131 -0
- package/src/tui/hooks/useConfigState.ts +171 -0
- package/src/tui/hooks/useKeyboardHandler.ts +91 -0
- package/src/tui/hooks/useLogStream.ts +96 -0
- package/src/tui/hooks/useSpinner.ts +46 -0
- package/src/tui/index.ts +65 -0
- package/src/tui/theme.ts +21 -0
- package/src/tui/utils/buildCliCommand.ts +90 -0
- package/src/tui/utils/index.ts +13 -0
- package/src/tui/utils/parameterPersistence.ts +96 -0
- package/src/tui/utils/schemaToFields.ts +144 -0
- package/src/types/command.ts +103 -0
- package/src/types/execution.ts +11 -0
- package/src/types/index.ts +1 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import type { Command, OptionSchema, OptionValues } from "../types/command.ts";
|
|
2
|
+
import { parseArgs, type ParseArgsConfig } from "util";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Result of parsing CLI arguments
|
|
6
|
+
*/
|
|
7
|
+
export interface ParseResult<T extends OptionSchema = OptionSchema> {
|
|
8
|
+
command: Command<T> | null;
|
|
9
|
+
commandPath: string[];
|
|
10
|
+
options: OptionValues<T>;
|
|
11
|
+
args: string[];
|
|
12
|
+
showHelp: boolean;
|
|
13
|
+
error?: ParseError;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Error during parsing
|
|
18
|
+
*/
|
|
19
|
+
export interface ParseError {
|
|
20
|
+
type: "unknown_command" | "invalid_option" | "missing_required" | "validation";
|
|
21
|
+
message: string;
|
|
22
|
+
field?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Extract command chain from args (commands before flags)
|
|
27
|
+
*/
|
|
28
|
+
export function extractCommandChain(args: string[]): {
|
|
29
|
+
commands: string[];
|
|
30
|
+
remaining: string[];
|
|
31
|
+
} {
|
|
32
|
+
const commands: string[] = [];
|
|
33
|
+
let i = 0;
|
|
34
|
+
|
|
35
|
+
for (; i < args.length; i++) {
|
|
36
|
+
const arg = args[i];
|
|
37
|
+
if (arg?.startsWith("-")) {
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
if (arg) {
|
|
41
|
+
commands.push(arg);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
commands,
|
|
47
|
+
remaining: args.slice(i),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Convert option schema to parseArgs config
|
|
53
|
+
*/
|
|
54
|
+
export function schemaToParseArgsOptions(schema: OptionSchema): {
|
|
55
|
+
options: ParseArgsConfig["options"];
|
|
56
|
+
} {
|
|
57
|
+
const options: NonNullable<ParseArgsConfig["options"]> = {};
|
|
58
|
+
|
|
59
|
+
for (const [name, def] of Object.entries(schema)) {
|
|
60
|
+
const parseArgsType = def.type === "boolean" ? "boolean" : "string";
|
|
61
|
+
|
|
62
|
+
const opt: NonNullable<ParseArgsConfig["options"]>[string] = {
|
|
63
|
+
type: parseArgsType,
|
|
64
|
+
multiple: def.type === "array",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// Only include short if alias is defined (parseArgs doesn't like undefined)
|
|
68
|
+
if (def.alias) {
|
|
69
|
+
opt.short = def.alias;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Only include default if defined
|
|
73
|
+
// For non-boolean types, parseArgs expects string defaults
|
|
74
|
+
if (def.default !== undefined) {
|
|
75
|
+
if (parseArgsType === "string" && typeof def.default !== "string") {
|
|
76
|
+
opt.default = String(def.default);
|
|
77
|
+
} else {
|
|
78
|
+
opt.default = def.default as string | boolean | string[] | boolean[];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
options[name] = opt;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { options };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Parse and coerce option values
|
|
90
|
+
*/
|
|
91
|
+
export function parseOptionValues<T extends OptionSchema>(
|
|
92
|
+
schema: T,
|
|
93
|
+
values: Record<string, unknown>
|
|
94
|
+
): OptionValues<T> {
|
|
95
|
+
const result: Record<string, unknown> = {};
|
|
96
|
+
|
|
97
|
+
for (const [name, def] of Object.entries(schema)) {
|
|
98
|
+
let value = values[name];
|
|
99
|
+
|
|
100
|
+
// Check environment variable
|
|
101
|
+
if (value === undefined && def.env) {
|
|
102
|
+
value = process.env[def.env];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Apply default
|
|
106
|
+
if (value === undefined && def.default !== undefined) {
|
|
107
|
+
value = def.default;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Coerce type
|
|
111
|
+
if (value !== undefined) {
|
|
112
|
+
if (def.type === "number" && typeof value === "string") {
|
|
113
|
+
value = Number(value);
|
|
114
|
+
} else if (def.type === "boolean" && typeof value === "string") {
|
|
115
|
+
value = value === "true" || value === "1";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Validate enum
|
|
119
|
+
if (def.enum && !def.enum.includes(String(value))) {
|
|
120
|
+
throw new Error(`Invalid value "${value}" for option "${name}". Must be one of: ${def.enum.join(", ")}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
result[name] = value;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return result as OptionValues<T>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Validate option values
|
|
132
|
+
*/
|
|
133
|
+
export function validateOptions<T extends OptionSchema>(
|
|
134
|
+
schema: T,
|
|
135
|
+
values: OptionValues<T>
|
|
136
|
+
): ParseError[] {
|
|
137
|
+
const errors: ParseError[] = [];
|
|
138
|
+
|
|
139
|
+
for (const [name, def] of Object.entries(schema)) {
|
|
140
|
+
const value = values[name as keyof typeof values];
|
|
141
|
+
|
|
142
|
+
if (def.required && value === undefined) {
|
|
143
|
+
errors.push({
|
|
144
|
+
type: "missing_required",
|
|
145
|
+
message: `Missing required option: ${name}`,
|
|
146
|
+
field: name,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (def.type === "number" && typeof value === "number") {
|
|
151
|
+
if (def.min !== undefined && value < def.min) {
|
|
152
|
+
errors.push({
|
|
153
|
+
type: "validation",
|
|
154
|
+
message: `Option "${name}" must be at least ${def.min}`,
|
|
155
|
+
field: name,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
if (def.max !== undefined && value > def.max) {
|
|
159
|
+
errors.push({
|
|
160
|
+
type: "validation",
|
|
161
|
+
message: `Option "${name}" must be at most ${def.max}`,
|
|
162
|
+
field: name,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return errors;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
interface ParseCliArgsOptions<T extends OptionSchema> {
|
|
172
|
+
args: string[];
|
|
173
|
+
commands: Record<string, Command<T>>;
|
|
174
|
+
defaultCommand?: string;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Parse CLI arguments into a result
|
|
179
|
+
*/
|
|
180
|
+
export function parseCliArgs<T extends OptionSchema>(
|
|
181
|
+
options: ParseCliArgsOptions<T>
|
|
182
|
+
): ParseResult<T> {
|
|
183
|
+
const { args, commands, defaultCommand } = options;
|
|
184
|
+
const { commands: commandChain, remaining } = extractCommandChain(args);
|
|
185
|
+
|
|
186
|
+
// Check for help flag
|
|
187
|
+
const showHelp = remaining.includes("--help") || remaining.includes("-h");
|
|
188
|
+
|
|
189
|
+
// Find command
|
|
190
|
+
const commandName = commandChain[0] ?? defaultCommand;
|
|
191
|
+
if (!commandName) {
|
|
192
|
+
return {
|
|
193
|
+
command: null,
|
|
194
|
+
commandPath: [],
|
|
195
|
+
options: {} as OptionValues<T>,
|
|
196
|
+
args: remaining,
|
|
197
|
+
showHelp,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const command = commands[commandName];
|
|
202
|
+
if (!command) {
|
|
203
|
+
return {
|
|
204
|
+
command: null,
|
|
205
|
+
commandPath: commandChain,
|
|
206
|
+
options: {} as OptionValues<T>,
|
|
207
|
+
args: remaining,
|
|
208
|
+
showHelp,
|
|
209
|
+
error: {
|
|
210
|
+
type: "unknown_command",
|
|
211
|
+
message: `Unknown command: ${commandName}`,
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Parse options
|
|
217
|
+
const schema = command.options ?? ({} as T);
|
|
218
|
+
const parseArgsConfig = schemaToParseArgsOptions(schema);
|
|
219
|
+
|
|
220
|
+
let parsedValues: Record<string, unknown> = {};
|
|
221
|
+
try {
|
|
222
|
+
const { values } = parseArgs({
|
|
223
|
+
args: remaining,
|
|
224
|
+
...parseArgsConfig,
|
|
225
|
+
allowPositionals: false,
|
|
226
|
+
});
|
|
227
|
+
parsedValues = values;
|
|
228
|
+
} catch {
|
|
229
|
+
// Ignore parse errors for now
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const optionValues = parseOptionValues(schema, parsedValues);
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
command,
|
|
236
|
+
commandPath: commandChain,
|
|
237
|
+
options: optionValues,
|
|
238
|
+
args: remaining,
|
|
239
|
+
showHelp,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { defineCommand, type Command } from "../types/command.ts";
|
|
2
|
+
import { generateHelp } from "../cli/help.ts";
|
|
3
|
+
|
|
4
|
+
interface HelpCommandOptions {
|
|
5
|
+
getCommands: () => Command[];
|
|
6
|
+
appName?: string;
|
|
7
|
+
version?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Create a help command
|
|
12
|
+
*/
|
|
13
|
+
export function createHelpCommand(options: HelpCommandOptions) {
|
|
14
|
+
const { getCommands, appName = "cli", version } = options;
|
|
15
|
+
|
|
16
|
+
return defineCommand({
|
|
17
|
+
name: "help",
|
|
18
|
+
description: "Show help information",
|
|
19
|
+
aliases: ["--help", "-h"],
|
|
20
|
+
hidden: true,
|
|
21
|
+
options: {
|
|
22
|
+
command: {
|
|
23
|
+
type: "string" as const,
|
|
24
|
+
description: "Command to show help for",
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
execute: (ctx) => {
|
|
28
|
+
const commands = getCommands();
|
|
29
|
+
const commandName = ctx.options["command"];
|
|
30
|
+
|
|
31
|
+
if (commandName && typeof commandName === "string") {
|
|
32
|
+
const cmd = commands.find((c) => c.name === commandName);
|
|
33
|
+
if (cmd) {
|
|
34
|
+
console.log(generateHelp(cmd, { appName, version }));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Show root help
|
|
40
|
+
const rootCommand = defineCommand({
|
|
41
|
+
name: appName,
|
|
42
|
+
description: `${appName} CLI`,
|
|
43
|
+
subcommands: Object.fromEntries(commands.map((c) => [c.name, c])),
|
|
44
|
+
execute: () => {},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
console.log(generateHelp(rootCommand, { appName, version }));
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./help.ts";
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Box component props
|
|
5
|
+
*/
|
|
6
|
+
export interface BoxProps {
|
|
7
|
+
children?: React.ReactNode;
|
|
8
|
+
flexDirection?: "row" | "column";
|
|
9
|
+
padding?: number;
|
|
10
|
+
margin?: number;
|
|
11
|
+
borderStyle?: "single" | "double" | "round" | "none";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Box component for layout
|
|
16
|
+
*/
|
|
17
|
+
export function Box({ children }: BoxProps) {
|
|
18
|
+
return React.createElement("div", null, children);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Text component props
|
|
23
|
+
*/
|
|
24
|
+
export interface TextProps {
|
|
25
|
+
children?: React.ReactNode;
|
|
26
|
+
color?: string;
|
|
27
|
+
bold?: boolean;
|
|
28
|
+
dim?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Text component for styled text
|
|
33
|
+
*/
|
|
34
|
+
export function Text({ children }: TextProps) {
|
|
35
|
+
return React.createElement("span", null, children);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Input component props
|
|
40
|
+
*/
|
|
41
|
+
export interface InputProps {
|
|
42
|
+
value: string;
|
|
43
|
+
onChange: (value: string) => void;
|
|
44
|
+
placeholder?: string;
|
|
45
|
+
disabled?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Input component
|
|
50
|
+
*/
|
|
51
|
+
export function Input({ value, onChange, placeholder }: InputProps) {
|
|
52
|
+
return React.createElement("input", {
|
|
53
|
+
value,
|
|
54
|
+
onChange: (e: { target: { value: string } }) => onChange(e.target.value),
|
|
55
|
+
placeholder,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Select option
|
|
61
|
+
*/
|
|
62
|
+
export interface SelectOption {
|
|
63
|
+
label: string;
|
|
64
|
+
value: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Select component props
|
|
69
|
+
*/
|
|
70
|
+
export interface SelectProps {
|
|
71
|
+
options: SelectOption[];
|
|
72
|
+
value?: string;
|
|
73
|
+
onChange: (value: string) => void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Select component
|
|
78
|
+
*/
|
|
79
|
+
export function Select({ options, value, onChange }: SelectProps) {
|
|
80
|
+
return React.createElement(
|
|
81
|
+
"select",
|
|
82
|
+
{ value, onChange: (e: { target: { value: string } }) => onChange(e.target.value) },
|
|
83
|
+
options.map((opt) =>
|
|
84
|
+
React.createElement("option", { key: opt.value, value: opt.value }, opt.label)
|
|
85
|
+
)
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Button component props
|
|
91
|
+
*/
|
|
92
|
+
export interface ButtonProps {
|
|
93
|
+
label: string;
|
|
94
|
+
onPress: () => void;
|
|
95
|
+
disabled?: boolean;
|
|
96
|
+
variant?: "primary" | "secondary" | "danger";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Button component
|
|
101
|
+
*/
|
|
102
|
+
export function Button({ label, onPress, disabled }: ButtonProps) {
|
|
103
|
+
return React.createElement("button", { onClick: onPress, disabled }, label);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Modal component props
|
|
108
|
+
*/
|
|
109
|
+
export interface ModalProps {
|
|
110
|
+
isOpen: boolean;
|
|
111
|
+
onClose: () => void;
|
|
112
|
+
title?: string;
|
|
113
|
+
children: React.ReactNode;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Modal component
|
|
118
|
+
*/
|
|
119
|
+
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
|
|
120
|
+
if (!isOpen) return null;
|
|
121
|
+
|
|
122
|
+
return React.createElement(
|
|
123
|
+
"div",
|
|
124
|
+
{ className: "modal" },
|
|
125
|
+
React.createElement(
|
|
126
|
+
"div",
|
|
127
|
+
{ className: "modal-content" },
|
|
128
|
+
title && React.createElement("h2", null, title),
|
|
129
|
+
children,
|
|
130
|
+
React.createElement("button", { onClick: onClose }, "Close")
|
|
131
|
+
)
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Spinner component props
|
|
137
|
+
*/
|
|
138
|
+
export interface SpinnerProps {
|
|
139
|
+
label?: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Spinner component
|
|
144
|
+
*/
|
|
145
|
+
export function Spinner({ label }: SpinnerProps) {
|
|
146
|
+
return React.createElement("span", null, label ?? "Loading...");
|
|
147
|
+
}
|