@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,96 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Log levels for display styling.
|
|
5
|
+
*/
|
|
6
|
+
export enum LogLevel {
|
|
7
|
+
Silly = "silly",
|
|
8
|
+
Trace = "trace",
|
|
9
|
+
Debug = "debug",
|
|
10
|
+
Info = "info",
|
|
11
|
+
Warn = "warn",
|
|
12
|
+
Error = "error",
|
|
13
|
+
Fatal = "fatal",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Log entry for display.
|
|
18
|
+
*/
|
|
19
|
+
export interface LogEntry {
|
|
20
|
+
timestamp: Date;
|
|
21
|
+
level: LogLevel;
|
|
22
|
+
message: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Log event emitted by the log source.
|
|
27
|
+
*/
|
|
28
|
+
export interface LogEvent {
|
|
29
|
+
level: LogLevel;
|
|
30
|
+
message: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Log source that can be subscribed to.
|
|
35
|
+
*/
|
|
36
|
+
export interface LogSource {
|
|
37
|
+
/** Subscribe to log events, returns unsubscribe function */
|
|
38
|
+
subscribe: (callback: (event: LogEvent) => void) => () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface UseLogStreamResult {
|
|
42
|
+
/** All collected log entries */
|
|
43
|
+
logs: LogEntry[];
|
|
44
|
+
/** Clear all logs */
|
|
45
|
+
clearLogs: () => void;
|
|
46
|
+
/** Add a log entry manually */
|
|
47
|
+
addLog: (level: LogLevel, message: string) => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Hook for subscribing to a log stream.
|
|
52
|
+
*
|
|
53
|
+
* @param source - Optional log source to subscribe to
|
|
54
|
+
* @returns Log stream state and functions
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```tsx
|
|
58
|
+
* const { logs, clearLogs } = useLogStream(myLogSource);
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
export function useLogStream(source?: LogSource): UseLogStreamResult {
|
|
62
|
+
const [logs, setLogs] = useState<LogEntry[]>([]);
|
|
63
|
+
|
|
64
|
+
// Subscribe to log source
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!source) return;
|
|
67
|
+
|
|
68
|
+
const unsubscribe = source.subscribe((event: LogEvent) => {
|
|
69
|
+
setLogs((prev) => {
|
|
70
|
+
const newEntry: LogEntry = {
|
|
71
|
+
timestamp: new Date(),
|
|
72
|
+
level: event.level,
|
|
73
|
+
message: event.message,
|
|
74
|
+
};
|
|
75
|
+
return [...prev, newEntry];
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return () => {
|
|
80
|
+
unsubscribe?.();
|
|
81
|
+
};
|
|
82
|
+
}, [source]);
|
|
83
|
+
|
|
84
|
+
const clearLogs = useCallback(() => {
|
|
85
|
+
setLogs([]);
|
|
86
|
+
}, []);
|
|
87
|
+
|
|
88
|
+
const addLog = useCallback((level: LogLevel, message: string) => {
|
|
89
|
+
setLogs((prev) => [
|
|
90
|
+
...prev,
|
|
91
|
+
{ timestamp: new Date(), level, message },
|
|
92
|
+
]);
|
|
93
|
+
}, []);
|
|
94
|
+
|
|
95
|
+
return { logs, clearLogs, addLog };
|
|
96
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useState, useEffect, useMemo } from "react";
|
|
2
|
+
|
|
3
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
4
|
+
const SPINNER_INTERVAL = 80;
|
|
5
|
+
|
|
6
|
+
export interface UseSpinnerResult {
|
|
7
|
+
/** Current frame index */
|
|
8
|
+
frameIndex: number;
|
|
9
|
+
/** Current spinner character */
|
|
10
|
+
frame: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Hook for animated spinner.
|
|
15
|
+
*
|
|
16
|
+
* @param active - Whether the spinner is active
|
|
17
|
+
* @returns Spinner state with current frame
|
|
18
|
+
*/
|
|
19
|
+
export function useSpinner(active: boolean): UseSpinnerResult {
|
|
20
|
+
const [frameIndex, setFrameIndex] = useState(0);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!active) {
|
|
24
|
+
setFrameIndex(0);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const interval = setInterval(() => {
|
|
29
|
+
setFrameIndex((prev) => {
|
|
30
|
+
// Reset to avoid overflow
|
|
31
|
+
if (prev >= Number.MAX_SAFE_INTEGER / 2) {
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
return prev + 1;
|
|
35
|
+
});
|
|
36
|
+
}, SPINNER_INTERVAL);
|
|
37
|
+
|
|
38
|
+
return () => clearInterval(interval);
|
|
39
|
+
}, [active]);
|
|
40
|
+
|
|
41
|
+
const frame = useMemo(() => {
|
|
42
|
+
return SPINNER_FRAMES[frameIndex % SPINNER_FRAMES.length]!;
|
|
43
|
+
}, [frameIndex]);
|
|
44
|
+
|
|
45
|
+
return { frameIndex, frame };
|
|
46
|
+
}
|
package/src/tui/index.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Main TUI components
|
|
2
|
+
export { TuiApp } from "./TuiApp.tsx";
|
|
3
|
+
export { TuiApplication, type TuiApplicationConfig, type CustomField } from "./TuiApplication.tsx";
|
|
4
|
+
|
|
5
|
+
// Theme
|
|
6
|
+
export { Theme, type ThemeColors } from "./theme.ts";
|
|
7
|
+
|
|
8
|
+
// Context
|
|
9
|
+
export {
|
|
10
|
+
KeyboardProvider,
|
|
11
|
+
useKeyboardContext,
|
|
12
|
+
KeyboardPriority,
|
|
13
|
+
type KeyboardEvent,
|
|
14
|
+
type KeyboardHandler,
|
|
15
|
+
} from "./context/index.ts";
|
|
16
|
+
|
|
17
|
+
// Hooks
|
|
18
|
+
export {
|
|
19
|
+
useKeyboardHandler,
|
|
20
|
+
useClipboard,
|
|
21
|
+
useSpinner,
|
|
22
|
+
useConfigState,
|
|
23
|
+
useCommandExecutor,
|
|
24
|
+
useLogStream,
|
|
25
|
+
LogLevel,
|
|
26
|
+
type UseClipboardResult,
|
|
27
|
+
type UseSpinnerResult,
|
|
28
|
+
type UseConfigStateOptions,
|
|
29
|
+
type UseConfigStateResult,
|
|
30
|
+
type UseCommandExecutorResult,
|
|
31
|
+
type LogEntry,
|
|
32
|
+
type LogEvent,
|
|
33
|
+
type LogSource,
|
|
34
|
+
type UseLogStreamResult,
|
|
35
|
+
} from "./hooks/index.ts";
|
|
36
|
+
|
|
37
|
+
// Components
|
|
38
|
+
export {
|
|
39
|
+
FieldRow,
|
|
40
|
+
ActionButton,
|
|
41
|
+
Header,
|
|
42
|
+
StatusBar,
|
|
43
|
+
LogsPanel,
|
|
44
|
+
ResultsPanel,
|
|
45
|
+
ConfigForm,
|
|
46
|
+
EditorModal,
|
|
47
|
+
CliModal,
|
|
48
|
+
CommandSelector,
|
|
49
|
+
JsonHighlight,
|
|
50
|
+
type FieldType,
|
|
51
|
+
type FieldOption,
|
|
52
|
+
type FieldConfig,
|
|
53
|
+
type JsonHighlightProps,
|
|
54
|
+
} from "./components/index.ts";
|
|
55
|
+
|
|
56
|
+
// Utilities
|
|
57
|
+
export {
|
|
58
|
+
schemaToFieldConfigs,
|
|
59
|
+
groupFieldConfigs,
|
|
60
|
+
getFieldDisplayValue,
|
|
61
|
+
buildCliCommand,
|
|
62
|
+
} from "./utils/index.ts";
|
|
63
|
+
|
|
64
|
+
// Legacy export for backward compatibility
|
|
65
|
+
export * from "./app.ts";
|
package/src/tui/theme.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default TUI theme colors.
|
|
3
|
+
*/
|
|
4
|
+
export const Theme = {
|
|
5
|
+
background: "#0b0c10",
|
|
6
|
+
border: "#2c2f36",
|
|
7
|
+
borderFocused: "#5da9e9",
|
|
8
|
+
borderSelected: "#61afef",
|
|
9
|
+
label: "#c0cad6",
|
|
10
|
+
value: "#98c379",
|
|
11
|
+
actionButton: "#a0e8af",
|
|
12
|
+
header: "#a8b3c1",
|
|
13
|
+
statusText: "#d6dde6",
|
|
14
|
+
overlay: "#0e1117",
|
|
15
|
+
overlayTitle: "#e5c07b",
|
|
16
|
+
error: "#f78888",
|
|
17
|
+
success: "#98c379",
|
|
18
|
+
warning: "#f5c542",
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
export type ThemeColors = typeof Theme;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { OptionSchema, OptionDef, OptionValues } from "../../types/command.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Escape a shell argument if it contains special characters.
|
|
5
|
+
*/
|
|
6
|
+
function escapeArg(arg: string): string {
|
|
7
|
+
if (/[^a-zA-Z0-9_\-./=]/.test(arg)) {
|
|
8
|
+
return `"${arg.replace(/"/g, '\\"')}"`;
|
|
9
|
+
}
|
|
10
|
+
return arg;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build a CLI command string from values and schema.
|
|
15
|
+
*
|
|
16
|
+
* @param appName - The application name (e.g., "myapp")
|
|
17
|
+
* @param commandPath - The command path (e.g., ["run"], ["db", "migrate"])
|
|
18
|
+
* @param schema - The option schema
|
|
19
|
+
* @param values - The current option values
|
|
20
|
+
* @returns CLI command string
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```typescript
|
|
24
|
+
* const cmd = buildCliCommand("myapp", ["run"], runOptions, config);
|
|
25
|
+
* // Returns: "myapp run --agent opencode --fixture test.json"
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export function buildCliCommand<T extends OptionSchema>(
|
|
29
|
+
appName: string,
|
|
30
|
+
commandPath: string[],
|
|
31
|
+
schema: T,
|
|
32
|
+
values: OptionValues<T>
|
|
33
|
+
): string {
|
|
34
|
+
const parts: string[] = [appName, ...commandPath];
|
|
35
|
+
|
|
36
|
+
for (const [key, def] of Object.entries(schema) as [keyof T & string, OptionDef][]) {
|
|
37
|
+
const value = values[key];
|
|
38
|
+
const defaultValue = def.default;
|
|
39
|
+
|
|
40
|
+
// Skip if value equals default (no need to include)
|
|
41
|
+
if (value === defaultValue) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Skip undefined/null values
|
|
46
|
+
if (value === undefined || value === null) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const optionName = toKebabCase(key);
|
|
51
|
+
|
|
52
|
+
switch (def.type) {
|
|
53
|
+
case "boolean":
|
|
54
|
+
if (value === true) {
|
|
55
|
+
parts.push(`--${optionName}`);
|
|
56
|
+
} else if (value === false && defaultValue === true) {
|
|
57
|
+
parts.push(`--no-${optionName}`);
|
|
58
|
+
}
|
|
59
|
+
break;
|
|
60
|
+
|
|
61
|
+
case "array":
|
|
62
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
63
|
+
for (const item of value) {
|
|
64
|
+
parts.push(`--${optionName}`, escapeArg(String(item)));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
break;
|
|
68
|
+
|
|
69
|
+
case "number":
|
|
70
|
+
parts.push(`--${optionName}`, String(value));
|
|
71
|
+
break;
|
|
72
|
+
|
|
73
|
+
case "string":
|
|
74
|
+
default:
|
|
75
|
+
if (String(value).trim() !== "") {
|
|
76
|
+
parts.push(`--${optionName}`, escapeArg(String(value)));
|
|
77
|
+
}
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return parts.join(" ");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Convert camelCase to kebab-case.
|
|
87
|
+
*/
|
|
88
|
+
function toKebabCase(str: string): string {
|
|
89
|
+
return str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
|
|
90
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export {
|
|
2
|
+
schemaToFieldConfigs,
|
|
3
|
+
groupFieldConfigs,
|
|
4
|
+
getFieldDisplayValue,
|
|
5
|
+
} from "./schemaToFields.ts";
|
|
6
|
+
|
|
7
|
+
export { buildCliCommand } from "./buildCliCommand.ts";
|
|
8
|
+
|
|
9
|
+
export {
|
|
10
|
+
loadPersistedParameters,
|
|
11
|
+
savePersistedParameters,
|
|
12
|
+
clearPersistedParameters,
|
|
13
|
+
} from "./parameterPersistence.ts";
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { homedir } from "os";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "fs";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Get the directory path for storing app configuration.
|
|
7
|
+
* Creates the directory if it doesn't exist.
|
|
8
|
+
*
|
|
9
|
+
* @param appName - The application name
|
|
10
|
+
* @returns The path to the app config directory
|
|
11
|
+
*/
|
|
12
|
+
function getAppConfigDir(appName: string): string {
|
|
13
|
+
const configDir = join(homedir(), `.${appName}`);
|
|
14
|
+
if (!existsSync(configDir)) {
|
|
15
|
+
mkdirSync(configDir, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
return configDir;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Get the file path for storing command parameters.
|
|
22
|
+
*
|
|
23
|
+
* @param appName - The application name
|
|
24
|
+
* @param commandName - The command name
|
|
25
|
+
* @returns The path to the parameters file
|
|
26
|
+
*/
|
|
27
|
+
function getParametersFilePath(appName: string, commandName: string): string {
|
|
28
|
+
const configDir = getAppConfigDir(appName);
|
|
29
|
+
return join(configDir, `${commandName}.parameters.json`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Load persisted parameters for a command.
|
|
34
|
+
*
|
|
35
|
+
* @param appName - The application name
|
|
36
|
+
* @param commandName - The command name
|
|
37
|
+
* @returns The persisted parameters, or an empty object if none exist
|
|
38
|
+
*/
|
|
39
|
+
export function loadPersistedParameters(
|
|
40
|
+
appName: string,
|
|
41
|
+
commandName: string
|
|
42
|
+
): Record<string, unknown> {
|
|
43
|
+
try {
|
|
44
|
+
const filePath = getParametersFilePath(appName, commandName);
|
|
45
|
+
if (existsSync(filePath)) {
|
|
46
|
+
const content = readFileSync(filePath, "utf-8");
|
|
47
|
+
return JSON.parse(content) as Record<string, unknown>;
|
|
48
|
+
}
|
|
49
|
+
} catch (error) {
|
|
50
|
+
// Silently ignore errors - just return empty object
|
|
51
|
+
console.error(`Failed to load persisted parameters: ${error}`);
|
|
52
|
+
}
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Save parameters for a command.
|
|
58
|
+
*
|
|
59
|
+
* @param appName - The application name
|
|
60
|
+
* @param commandName - The command name
|
|
61
|
+
* @param parameters - The parameters to persist
|
|
62
|
+
*/
|
|
63
|
+
export function savePersistedParameters(
|
|
64
|
+
appName: string,
|
|
65
|
+
commandName: string,
|
|
66
|
+
parameters: Record<string, unknown>
|
|
67
|
+
): void {
|
|
68
|
+
try {
|
|
69
|
+
const filePath = getParametersFilePath(appName, commandName);
|
|
70
|
+
writeFileSync(filePath, JSON.stringify(parameters, null, 2), "utf-8");
|
|
71
|
+
} catch (error) {
|
|
72
|
+
// Silently ignore errors
|
|
73
|
+
console.error(`Failed to save persisted parameters: ${error}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Clear persisted parameters for a command.
|
|
79
|
+
*
|
|
80
|
+
* @param appName - The application name
|
|
81
|
+
* @param commandName - The command name
|
|
82
|
+
*/
|
|
83
|
+
export function clearPersistedParameters(
|
|
84
|
+
appName: string,
|
|
85
|
+
commandName: string
|
|
86
|
+
): void {
|
|
87
|
+
try {
|
|
88
|
+
const filePath = getParametersFilePath(appName, commandName);
|
|
89
|
+
if (existsSync(filePath)) {
|
|
90
|
+
unlinkSync(filePath);
|
|
91
|
+
}
|
|
92
|
+
} catch (error) {
|
|
93
|
+
// Silently ignore errors
|
|
94
|
+
console.error(`Failed to clear persisted parameters: ${error}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { OptionSchema, OptionDef } from "../../types/command.ts";
|
|
2
|
+
import type { FieldConfig, FieldType, FieldOption } from "../components/types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Convert an option type to a field type.
|
|
6
|
+
*/
|
|
7
|
+
function optionTypeToFieldType(def: OptionDef): FieldType {
|
|
8
|
+
// If it has enum values, it's an enum type
|
|
9
|
+
if (def.enum && def.enum.length > 0) {
|
|
10
|
+
return "enum";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
switch (def.type) {
|
|
14
|
+
case "string":
|
|
15
|
+
return "text";
|
|
16
|
+
case "number":
|
|
17
|
+
return "number";
|
|
18
|
+
case "boolean":
|
|
19
|
+
return "boolean";
|
|
20
|
+
case "array":
|
|
21
|
+
return "text"; // Arrays are edited as comma-separated text
|
|
22
|
+
default:
|
|
23
|
+
return "text";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Create field options from enum values.
|
|
29
|
+
*/
|
|
30
|
+
function createFieldOptions(def: OptionDef): FieldOption[] | undefined {
|
|
31
|
+
if (!def.enum || def.enum.length === 0) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return def.enum.map((value) => ({
|
|
36
|
+
name: String(value),
|
|
37
|
+
value,
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Create a label from a key name.
|
|
43
|
+
* Converts camelCase to Title Case.
|
|
44
|
+
*/
|
|
45
|
+
function keyToLabel(key: string): string {
|
|
46
|
+
return key
|
|
47
|
+
.replace(/([A-Z])/g, " $1")
|
|
48
|
+
.replace(/^./, (str) => str.toUpperCase())
|
|
49
|
+
.trim();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Convert an option schema to field configs for TUI forms.
|
|
54
|
+
*
|
|
55
|
+
* @param schema - The option schema to convert
|
|
56
|
+
* @returns Array of field configs sorted by order
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```typescript
|
|
60
|
+
* const fields = schemaToFieldConfigs(myOptions);
|
|
61
|
+
* // Returns FieldConfig[] ready for ConfigForm
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
export function schemaToFieldConfigs(schema: OptionSchema): FieldConfig[] {
|
|
65
|
+
const fields: FieldConfig[] = [];
|
|
66
|
+
|
|
67
|
+
for (const [key, def] of Object.entries(schema)) {
|
|
68
|
+
// Skip hidden fields
|
|
69
|
+
if (def.tuiHidden) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const fieldConfig: FieldConfig = {
|
|
74
|
+
key,
|
|
75
|
+
label: def.label ?? keyToLabel(key),
|
|
76
|
+
type: optionTypeToFieldType(def),
|
|
77
|
+
options: createFieldOptions(def),
|
|
78
|
+
placeholder: def.placeholder,
|
|
79
|
+
group: def.group,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
fields.push(fieldConfig);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Sort by order if specified, otherwise preserve insertion order
|
|
86
|
+
fields.sort((a, b) => {
|
|
87
|
+
const orderA = schema[a.key]?.order ?? Number.MAX_SAFE_INTEGER;
|
|
88
|
+
const orderB = schema[b.key]?.order ?? Number.MAX_SAFE_INTEGER;
|
|
89
|
+
return orderA - orderB;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return fields;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Group field configs by their group property.
|
|
97
|
+
*
|
|
98
|
+
* @param fields - Field configs to group
|
|
99
|
+
* @returns Map of group name to field configs
|
|
100
|
+
*/
|
|
101
|
+
export function groupFieldConfigs(
|
|
102
|
+
fields: FieldConfig[]
|
|
103
|
+
): Map<string | undefined, FieldConfig[]> {
|
|
104
|
+
const groups = new Map<string | undefined, FieldConfig[]>();
|
|
105
|
+
|
|
106
|
+
for (const field of fields) {
|
|
107
|
+
const group = field.group;
|
|
108
|
+
if (!groups.has(group)) {
|
|
109
|
+
groups.set(group, []);
|
|
110
|
+
}
|
|
111
|
+
groups.get(group)!.push(field);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return groups;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Get display value for a field.
|
|
119
|
+
*
|
|
120
|
+
* @param value - The field value
|
|
121
|
+
* @param fieldConfig - The field configuration
|
|
122
|
+
* @returns Formatted display string
|
|
123
|
+
*/
|
|
124
|
+
export function getFieldDisplayValue(
|
|
125
|
+
value: unknown,
|
|
126
|
+
fieldConfig: FieldConfig
|
|
127
|
+
): string {
|
|
128
|
+
if (fieldConfig.type === "boolean") {
|
|
129
|
+
return value ? "True" : "False";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (fieldConfig.type === "enum" && fieldConfig.options) {
|
|
133
|
+
const option = fieldConfig.options.find((o) => o.value === value);
|
|
134
|
+
return option?.name ?? String(value);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const strValue = String(value ?? "");
|
|
138
|
+
if (strValue === "") {
|
|
139
|
+
return "(empty)";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Truncate long values
|
|
143
|
+
return strValue.length > 60 ? strValue.substring(0, 57) + "..." : strValue;
|
|
144
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Option definition for command-line arguments
|
|
3
|
+
*/
|
|
4
|
+
export interface OptionDef {
|
|
5
|
+
type: "string" | "number" | "boolean" | "array";
|
|
6
|
+
description: string;
|
|
7
|
+
alias?: string;
|
|
8
|
+
default?: unknown;
|
|
9
|
+
required?: boolean;
|
|
10
|
+
env?: string;
|
|
11
|
+
enum?: readonly string[];
|
|
12
|
+
min?: number;
|
|
13
|
+
max?: number;
|
|
14
|
+
|
|
15
|
+
// TUI-specific properties
|
|
16
|
+
/** Display label in TUI form (defaults to key name) */
|
|
17
|
+
label?: string;
|
|
18
|
+
/** Display order in TUI form (lower = first) */
|
|
19
|
+
order?: number;
|
|
20
|
+
/** Group name for organizing fields in TUI */
|
|
21
|
+
group?: string;
|
|
22
|
+
/** Placeholder text for input fields */
|
|
23
|
+
placeholder?: string;
|
|
24
|
+
/** Hide this field from TUI (still available in CLI) */
|
|
25
|
+
tuiHidden?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Schema defining all options for a command
|
|
30
|
+
*/
|
|
31
|
+
export type OptionSchema = Record<string, OptionDef>;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Inferred option values from a schema
|
|
35
|
+
*/
|
|
36
|
+
export type OptionValues<T extends OptionSchema> = {
|
|
37
|
+
[K in keyof T]: T[K]["type"] extends "string" ? string
|
|
38
|
+
: T[K]["type"] extends "number" ? number
|
|
39
|
+
: T[K]["type"] extends "boolean" ? boolean
|
|
40
|
+
: T[K]["type"] extends "array" ? string[]
|
|
41
|
+
: unknown;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Context passed to command executors
|
|
46
|
+
*/
|
|
47
|
+
export interface CommandContext<T extends OptionSchema = OptionSchema> {
|
|
48
|
+
options: OptionValues<T>;
|
|
49
|
+
args: string[];
|
|
50
|
+
commandPath: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Command executor function
|
|
55
|
+
*/
|
|
56
|
+
export type CommandExecutor<T extends OptionSchema = OptionSchema> = (
|
|
57
|
+
ctx: CommandContext<T>
|
|
58
|
+
) => void | Promise<void>;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Command definition
|
|
62
|
+
*/
|
|
63
|
+
export interface Command<
|
|
64
|
+
T extends OptionSchema = OptionSchema,
|
|
65
|
+
R = void,
|
|
66
|
+
> {
|
|
67
|
+
name: string;
|
|
68
|
+
description: string;
|
|
69
|
+
aliases?: string[];
|
|
70
|
+
hidden?: boolean;
|
|
71
|
+
options?: T;
|
|
72
|
+
subcommands?: Record<string, Command>;
|
|
73
|
+
examples?: Array<{ command: string; description: string }>;
|
|
74
|
+
execute: (ctx: CommandContext<T>) => R | Promise<R>;
|
|
75
|
+
beforeExecute?: (ctx: CommandContext<T>) => void | Promise<void>;
|
|
76
|
+
afterExecute?: (ctx: CommandContext<T>) => void | Promise<void>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* TUI command with a render function
|
|
81
|
+
*/
|
|
82
|
+
export interface TuiCommand<T extends OptionSchema = OptionSchema>
|
|
83
|
+
extends Omit<Command<T>, "execute"> {
|
|
84
|
+
render: (ctx: CommandContext<T>) => React.ReactNode;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Define a CLI command
|
|
89
|
+
*/
|
|
90
|
+
export function defineCommand<T extends OptionSchema = OptionSchema>(
|
|
91
|
+
config: Command<T>
|
|
92
|
+
): Command<T> {
|
|
93
|
+
return config;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Define a TUI command
|
|
98
|
+
*/
|
|
99
|
+
export function defineTuiCommand<T extends OptionSchema = OptionSchema>(
|
|
100
|
+
config: TuiCommand<T>
|
|
101
|
+
): TuiCommand<T> {
|
|
102
|
+
return config;
|
|
103
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execution mode determines how a command runs.
|
|
3
|
+
* - Cli: Command-line mode with arguments, executes and exits
|
|
4
|
+
* - Tui: Terminal UI mode with interactive rendering
|
|
5
|
+
*/
|
|
6
|
+
export enum ExecutionMode {
|
|
7
|
+
/** Command-line mode: parse args, execute, exit */
|
|
8
|
+
Cli = "cli",
|
|
9
|
+
/** Terminal UI mode: interactive render loop */
|
|
10
|
+
Tui = "tui",
|
|
11
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./command.ts";
|