@pablozaiden/terminatui 0.2.0 → 0.3.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.
Files changed (181) hide show
  1. package/README.md +64 -43
  2. package/package.json +11 -8
  3. package/src/__tests__/application.test.ts +87 -68
  4. package/src/__tests__/buildCliCommand.test.ts +99 -119
  5. package/src/__tests__/builtins.test.ts +27 -75
  6. package/src/__tests__/command.test.ts +100 -131
  7. package/src/__tests__/configOnChange.test.ts +63 -0
  8. package/src/__tests__/context.test.ts +1 -26
  9. package/src/__tests__/helpCore.test.ts +227 -0
  10. package/src/__tests__/parser.test.ts +98 -244
  11. package/src/__tests__/registry.test.ts +33 -160
  12. package/src/__tests__/schemaToFields.test.ts +75 -158
  13. package/src/builtins/help.ts +12 -4
  14. package/src/builtins/settings.ts +18 -32
  15. package/src/builtins/version.ts +3 -3
  16. package/src/cli/output/colors.ts +1 -1
  17. package/src/cli/parser.ts +26 -95
  18. package/src/core/application.ts +192 -110
  19. package/src/core/command.ts +26 -9
  20. package/src/core/context.ts +31 -20
  21. package/src/core/help.ts +24 -18
  22. package/src/core/knownCommands.ts +13 -0
  23. package/src/core/logger.ts +39 -42
  24. package/src/core/registry.ts +5 -12
  25. package/src/index.ts +22 -137
  26. package/src/tui/TuiApplication.tsx +63 -120
  27. package/src/tui/TuiRoot.tsx +135 -0
  28. package/src/tui/adapters/factory.ts +19 -0
  29. package/src/tui/adapters/ink/InkRenderer.tsx +139 -0
  30. package/src/tui/adapters/ink/components/Button.tsx +12 -0
  31. package/src/tui/adapters/ink/components/Code.tsx +6 -0
  32. package/src/tui/adapters/ink/components/CodeHighlight.tsx +6 -0
  33. package/src/tui/adapters/ink/components/Container.tsx +5 -0
  34. package/src/tui/adapters/ink/components/Field.tsx +12 -0
  35. package/src/tui/adapters/ink/components/Label.tsx +24 -0
  36. package/src/tui/adapters/ink/components/MenuButton.tsx +12 -0
  37. package/src/tui/adapters/ink/components/MenuItem.tsx +17 -0
  38. package/src/tui/adapters/ink/components/Overlay.tsx +5 -0
  39. package/src/tui/adapters/ink/components/Panel.tsx +15 -0
  40. package/src/tui/adapters/ink/components/ScrollView.tsx +5 -0
  41. package/src/tui/adapters/ink/components/Select.tsx +44 -0
  42. package/src/tui/adapters/ink/components/Spacer.tsx +15 -0
  43. package/src/tui/adapters/ink/components/Spinner.tsx +5 -0
  44. package/src/tui/adapters/ink/components/TextInput.tsx +22 -0
  45. package/src/tui/adapters/ink/components/Value.tsx +7 -0
  46. package/src/tui/adapters/ink/keyboard.ts +97 -0
  47. package/src/tui/adapters/ink/utils.ts +16 -0
  48. package/src/tui/adapters/opentui/OpenTuiRenderer.tsx +119 -0
  49. package/src/tui/adapters/opentui/components/Button.tsx +13 -0
  50. package/src/tui/adapters/opentui/components/Code.tsx +12 -0
  51. package/src/tui/adapters/opentui/components/CodeHighlight.tsx +24 -0
  52. package/src/tui/adapters/opentui/components/Container.tsx +56 -0
  53. package/src/tui/adapters/opentui/components/Field.tsx +18 -0
  54. package/src/tui/adapters/opentui/components/Label.tsx +15 -0
  55. package/src/tui/adapters/opentui/components/MenuButton.tsx +14 -0
  56. package/src/tui/adapters/opentui/components/MenuItem.tsx +29 -0
  57. package/src/tui/adapters/opentui/components/Overlay.tsx +21 -0
  58. package/src/tui/adapters/opentui/components/Panel.tsx +78 -0
  59. package/src/tui/adapters/opentui/components/ScrollView.tsx +85 -0
  60. package/src/tui/adapters/opentui/components/Select.tsx +59 -0
  61. package/src/tui/adapters/opentui/components/Spacer.tsx +5 -0
  62. package/src/tui/adapters/opentui/components/Spinner.tsx +12 -0
  63. package/src/tui/adapters/opentui/components/TextInput.tsx +13 -0
  64. package/src/tui/adapters/opentui/components/Value.tsx +13 -0
  65. package/src/tui/{hooks → adapters/opentui/hooks}/useSpinner.ts +2 -11
  66. package/src/tui/adapters/opentui/keyboard.ts +61 -0
  67. package/src/tui/adapters/types.ts +71 -0
  68. package/src/tui/components/ActionButton.tsx +0 -36
  69. package/src/tui/components/CommandSelector.tsx +45 -92
  70. package/src/tui/components/ConfigForm.tsx +68 -42
  71. package/src/tui/components/FieldRow.tsx +0 -30
  72. package/src/tui/components/Header.tsx +14 -13
  73. package/src/tui/components/JsonHighlight.tsx +10 -17
  74. package/src/tui/components/ModalBase.tsx +38 -0
  75. package/src/tui/components/ResultsPanel.tsx +27 -36
  76. package/src/tui/components/StatusBar.tsx +24 -39
  77. package/src/tui/components/logColors.ts +12 -0
  78. package/src/tui/context/ClipboardContext.tsx +87 -0
  79. package/src/tui/context/ExecutorContext.tsx +139 -0
  80. package/src/tui/context/KeyboardContext.tsx +85 -71
  81. package/src/tui/context/LogsContext.tsx +35 -0
  82. package/src/tui/context/NavigationContext.tsx +194 -0
  83. package/src/tui/context/RendererContext.tsx +20 -0
  84. package/src/tui/context/TuiAppContext.tsx +58 -0
  85. package/src/tui/hooks/useActiveKeyHandler.ts +75 -0
  86. package/src/tui/hooks/useBackHandler.ts +34 -0
  87. package/src/tui/hooks/useClipboard.ts +40 -25
  88. package/src/tui/hooks/useClipboardProvider.ts +42 -0
  89. package/src/tui/hooks/useGlobalKeyHandler.ts +54 -0
  90. package/src/tui/modals/CliModal.tsx +82 -0
  91. package/src/tui/modals/EditorModal.tsx +207 -0
  92. package/src/tui/modals/LogsModal.tsx +98 -0
  93. package/src/tui/registry.ts +102 -0
  94. package/src/tui/screens/CommandSelectScreen.tsx +162 -0
  95. package/src/tui/screens/ConfigScreen.tsx +165 -0
  96. package/src/tui/screens/ErrorScreen.tsx +58 -0
  97. package/src/tui/screens/ResultsScreen.tsx +68 -0
  98. package/src/tui/screens/RunningScreen.tsx +72 -0
  99. package/src/tui/screens/ScreenBase.ts +6 -0
  100. package/src/tui/semantic/Button.tsx +7 -0
  101. package/src/tui/semantic/Code.tsx +7 -0
  102. package/src/tui/semantic/CodeHighlight.tsx +7 -0
  103. package/src/tui/semantic/Container.tsx +7 -0
  104. package/src/tui/semantic/Field.tsx +7 -0
  105. package/src/tui/semantic/Label.tsx +7 -0
  106. package/src/tui/semantic/MenuButton.tsx +7 -0
  107. package/src/tui/semantic/MenuItem.tsx +7 -0
  108. package/src/tui/semantic/Overlay.tsx +7 -0
  109. package/src/tui/semantic/Panel.tsx +7 -0
  110. package/src/tui/semantic/ScrollView.tsx +9 -0
  111. package/src/tui/semantic/Select.tsx +7 -0
  112. package/src/tui/semantic/Spacer.tsx +7 -0
  113. package/src/tui/semantic/Spinner.tsx +7 -0
  114. package/src/tui/semantic/TextInput.tsx +7 -0
  115. package/src/tui/semantic/Value.tsx +7 -0
  116. package/src/tui/semantic/types.ts +195 -0
  117. package/src/tui/theme.ts +25 -14
  118. package/src/tui/utils/buildCliCommand.ts +1 -0
  119. package/src/tui/utils/getEnumKeys.ts +3 -0
  120. package/src/tui/utils/parameterPersistence.ts +1 -0
  121. package/src/types/command.ts +0 -60
  122. package/.devcontainer/devcontainer.json +0 -19
  123. package/.devcontainer/install-prerequisites.sh +0 -49
  124. package/.github/workflows/copilot-setup-steps.yml +0 -32
  125. package/.github/workflows/pull-request.yml +0 -27
  126. package/.github/workflows/release-npm-package.yml +0 -81
  127. package/AGENTS.md +0 -31
  128. package/bun.lock +0 -236
  129. package/examples/tui-app/commands/config/app/get.ts +0 -66
  130. package/examples/tui-app/commands/config/app/index.ts +0 -27
  131. package/examples/tui-app/commands/config/app/set.ts +0 -86
  132. package/examples/tui-app/commands/config/index.ts +0 -32
  133. package/examples/tui-app/commands/config/user/get.ts +0 -65
  134. package/examples/tui-app/commands/config/user/index.ts +0 -27
  135. package/examples/tui-app/commands/config/user/set.ts +0 -61
  136. package/examples/tui-app/commands/greet.ts +0 -76
  137. package/examples/tui-app/commands/index.ts +0 -4
  138. package/examples/tui-app/commands/math.ts +0 -115
  139. package/examples/tui-app/commands/status.ts +0 -77
  140. package/examples/tui-app/index.ts +0 -35
  141. package/guides/01-hello-world.md +0 -96
  142. package/guides/02-adding-options.md +0 -103
  143. package/guides/03-multiple-commands.md +0 -163
  144. package/guides/04-subcommands.md +0 -206
  145. package/guides/05-interactive-tui.md +0 -194
  146. package/guides/06-config-validation.md +0 -264
  147. package/guides/07-async-cancellation.md +0 -336
  148. package/guides/08-complete-application.md +0 -537
  149. package/guides/README.md +0 -74
  150. package/src/__tests__/colors.test.ts +0 -127
  151. package/src/__tests__/commandClass.test.ts +0 -130
  152. package/src/__tests__/help.test.ts +0 -412
  153. package/src/__tests__/registryNew.test.ts +0 -160
  154. package/src/__tests__/table.test.ts +0 -146
  155. package/src/__tests__/tui.test.ts +0 -26
  156. package/src/builtins/index.ts +0 -4
  157. package/src/cli/help.ts +0 -174
  158. package/src/cli/index.ts +0 -3
  159. package/src/cli/output/index.ts +0 -2
  160. package/src/cli/output/table.ts +0 -141
  161. package/src/commands/help.ts +0 -50
  162. package/src/commands/index.ts +0 -1
  163. package/src/components/index.ts +0 -147
  164. package/src/core/index.ts +0 -15
  165. package/src/hooks/index.ts +0 -131
  166. package/src/registry/commandRegistry.ts +0 -77
  167. package/src/registry/index.ts +0 -1
  168. package/src/tui/TuiApp.tsx +0 -619
  169. package/src/tui/app.ts +0 -29
  170. package/src/tui/components/CliModal.tsx +0 -81
  171. package/src/tui/components/EditorModal.tsx +0 -177
  172. package/src/tui/components/LogsPanel.tsx +0 -86
  173. package/src/tui/components/index.ts +0 -13
  174. package/src/tui/context/index.ts +0 -7
  175. package/src/tui/hooks/index.ts +0 -35
  176. package/src/tui/hooks/useKeyboardHandler.ts +0 -91
  177. package/src/tui/hooks/useLogStream.ts +0 -96
  178. package/src/tui/index.ts +0 -65
  179. package/src/tui/utils/index.ts +0 -13
  180. package/src/types/index.ts +0 -1
  181. package/tsconfig.json +0 -25
package/src/cli/parser.ts CHANGED
@@ -1,21 +1,6 @@
1
- import type { Command, OptionSchema, OptionValues } from "../types/command.ts";
2
- import { parseArgs, type ParseArgsConfig } from "util";
1
+ import type { OptionSchema, OptionValues } from "../types/command.ts";
2
+ import { type ParseArgsConfig } from "util";
3
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
4
  export interface ParseError {
20
5
  type: "unknown_command" | "invalid_option" | "missing_required" | "validation";
21
6
  message: string;
@@ -30,21 +15,38 @@ export function extractCommandChain(args: string[]): {
30
15
  remaining: string[];
31
16
  } {
32
17
  const commands: string[] = [];
33
- let i = 0;
18
+ const remaining: string[] = [];
34
19
 
35
- for (; i < args.length; i++) {
20
+ for (let i = 0; i < args.length; i++) {
36
21
  const arg = args[i];
37
- if (arg?.startsWith("-")) {
38
- break;
22
+
23
+ if (!arg) {
24
+ continue;
39
25
  }
40
- if (arg) {
41
- commands.push(arg);
26
+
27
+ if (arg.startsWith("-")) {
28
+ remaining.push(arg);
29
+
30
+ const next = args[i + 1];
31
+ if (next && !next.startsWith("-")) {
32
+ remaining.push(next);
33
+ i += 1;
34
+ }
35
+
36
+ continue;
42
37
  }
38
+
39
+ if (remaining.length > 0) {
40
+ remaining.push(arg);
41
+ continue;
42
+ }
43
+
44
+ commands.push(arg);
43
45
  }
44
46
 
45
47
  return {
46
48
  commands,
47
- remaining: args.slice(i),
49
+ remaining,
48
50
  };
49
51
  }
50
52
 
@@ -168,74 +170,3 @@ export function validateOptions<T extends OptionSchema>(
168
170
  return errors;
169
171
  }
170
172
 
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
- }
@@ -1,31 +1,52 @@
1
1
  import { AppContext, type AppConfig } from "./context.ts";
2
- import { type AnyCommand, ConfigValidationError, type CommandResult } from "./command.ts";
2
+ import { type AnyCommand, ConfigValidationError, type CommandResult, type CommandExecutionContext } from "./command.ts";
3
3
  import { CommandRegistry } from "./registry.ts";
4
4
  import { ExecutionMode } from "../types/execution.ts";
5
5
  import { LogLevel, type LoggerConfig } from "./logger.ts";
6
- import { generateAppHelp, generateCommandHelp } from "./help.ts";
7
- import {
8
- createVersionCommand,
9
- createHelpCommandForParent,
10
- createRootHelpCommand,
11
- } from "../builtins/index.ts";
12
6
  import {
13
7
  extractCommandChain,
14
8
  schemaToParseArgsOptions,
15
9
  parseOptionValues,
16
10
  validateOptions,
17
11
  } from "../cli/parser.ts";
12
+ import type { OptionSchema } from "../types/command.ts";
18
13
  import { parseArgs, type ParseArgsConfig } from "util";
14
+ import { createVersionCommand } from "../builtins/version.ts";
15
+ import { createHelpCommandForParent, createRootHelpCommand } from "../builtins/help.ts";
16
+ import { KNOWN_COMMANDS, RESERVED_TOP_LEVEL_COMMAND_NAMES } from "./knownCommands.ts";
19
17
 
20
18
  /**
21
19
  * Global options available on all commands.
22
20
  * These are handled by the framework before dispatching to commands.
23
21
  */
22
+
23
+ export type TuiModeOptions = "opentui" | "ink";
24
+ export type ModeOptions = TuiModeOptions | "cli" | "default";
25
+
24
26
  export interface GlobalOptions {
25
27
  "log-level"?: string;
26
28
  "detailed-logs"?: boolean;
29
+ "mode"?: string;
27
30
  }
28
31
 
32
+ export const GLOBAL_OPTIONS_SCHEMA = {
33
+ "log-level": {
34
+ type: "string",
35
+ description: "Minimum log level (e.g. info, debug)",
36
+ },
37
+ "detailed-logs": {
38
+ type: "boolean",
39
+ description: "Enable detailed logging",
40
+ default: false,
41
+ },
42
+ mode: {
43
+ type: "string",
44
+ description: "Execution mode",
45
+ default: "default",
46
+ enum: ["opentui", "ink", "cli", "default"],
47
+ },
48
+ } satisfies OptionSchema;
49
+
29
50
  /**
30
51
  * Application configuration options.
31
52
  */
@@ -53,11 +74,11 @@ export interface ApplicationConfig {
53
74
  */
54
75
  export interface ApplicationHooks {
55
76
  /** Called before running any command */
56
- onBeforeRun?: (ctx: AppContext, commandName: string) => Promise<void> | void;
77
+ onBeforeRun?: (commandName: string) => Promise<void> | void;
57
78
  /** Called after command completes (success or failure) */
58
- onAfterRun?: (ctx: AppContext, commandName: string, error?: Error) => Promise<void> | void;
79
+ onAfterRun?: (commandName: string, error?: Error) => Promise<void> | void;
59
80
  /** Called when an error occurs */
60
- onError?: (ctx: AppContext, error: Error) => Promise<void> | void;
81
+ onError?: (error: Error) => Promise<void> | void;
61
82
  }
62
83
 
63
84
  /**
@@ -72,19 +93,24 @@ export interface ApplicationHooks {
72
93
  * name: "myapp",
73
94
  * version: "1.0.0",
74
95
  * commands: [new RunCommand(), new CheckCommand()],
75
- * defaultCommand: "interactive",
96
+ * defaultCommand: "version",
76
97
  * });
77
98
  *
78
- * await app.run(process.argv.slice(2));
99
+ * await app.run();
79
100
  * ```
80
101
  */
81
102
  export class Application {
82
103
  readonly name: string;
104
+
105
+ /**
106
+ * Default mode used when `--mode=default` is specified.
107
+ * Base Application defaults to `cli`.
108
+ */
109
+ protected defaultMode: ModeOptions = "cli";
83
110
  readonly displayName: string;
84
111
  readonly version: string;
85
112
  readonly commitHash?: string;
86
113
  readonly registry: CommandRegistry;
87
- readonly context: AppContext;
88
114
 
89
115
  private readonly defaultCommandName?: string;
90
116
  private hooks: ApplicationHooks = {};
@@ -102,8 +128,10 @@ export class Application {
102
128
  version: config.version,
103
129
  ...config.config,
104
130
  };
105
- this.context = new AppContext(appConfig, config.logger);
106
- AppContext.setCurrent(this.context);
131
+ const context = new AppContext(appConfig, config.logger);
132
+ AppContext.setCurrent(context);
133
+
134
+ context.logger.silly(`Application initialized: ${this.name} v${this.version}`);
107
135
 
108
136
  // Create registry and register commands
109
137
  this.registry = new CommandRegistry();
@@ -114,6 +142,8 @@ export class Application {
114
142
  * Register commands and inject help subcommands.
115
143
  */
116
144
  private registerCommands(commands: AnyCommand[]): void {
145
+ this.assertNoReservedCommands(commands);
146
+
117
147
  // Register version command at top level
118
148
  this.registry.register(createVersionCommand(this.name, this.version, this.commitHash));
119
149
 
@@ -124,7 +154,35 @@ export class Application {
124
154
  }
125
155
 
126
156
  // Register root help command
127
- this.registry.register(createRootHelpCommand(commands, this.name, this.version));
157
+ // Use the full registry list so built-ins like `version` are included.
158
+ this.registry.register(createRootHelpCommand(this.registry.list(), this.name, this.version));
159
+ }
160
+
161
+ private assertNoReservedCommands(commands: AnyCommand[]): void {
162
+ for (const command of commands) {
163
+ this.assertNoReservedCommand(command, []);
164
+ }
165
+ }
166
+
167
+ private assertNoReservedCommand(command: AnyCommand, path: string[]): void {
168
+ if (RESERVED_TOP_LEVEL_COMMAND_NAMES.has(command.name as never)) {
169
+ throw new Error(
170
+ `Command name '${command.name}' is reserved by Terminatui and cannot be registered`
171
+ );
172
+ }
173
+
174
+ if (command.subCommands) {
175
+ for (const subCommand of command.subCommands) {
176
+ if (subCommand.name === KNOWN_COMMANDS.help) {
177
+ const commandPath = [...path, command.name].join(" ");
178
+ throw new Error(
179
+ `Subcommand name '${KNOWN_COMMANDS.help}' is reserved and is automatically injected (found under '${commandPath}')`
180
+ );
181
+ }
182
+
183
+ this.assertNoReservedCommand(subCommand, [...path, command.name]);
184
+ }
185
+ }
128
186
  }
129
187
 
130
188
  /**
@@ -144,10 +202,11 @@ export class Application {
144
202
 
145
203
  // Recursively inject into subcommands
146
204
  for (const subCommand of command.subCommands) {
147
- if (subCommand.name !== "help") {
205
+ if (subCommand.name !== KNOWN_COMMANDS.help) {
148
206
  this.injectHelpCommand(subCommand);
149
207
  }
150
208
  }
209
+
151
210
  }
152
211
 
153
212
  /**
@@ -158,16 +217,39 @@ export class Application {
158
217
  }
159
218
 
160
219
  /**
161
- * Run the application with the given arguments.
162
- *
163
- * @param argv Command-line arguments (typically process.argv.slice(2))
220
+ * Run the application using Bun's process args.
221
+ *
222
+ * This is the common entrypoint for real apps.
223
+ */
224
+ async run(): Promise<void> {
225
+ return this.runFromArgs(Bun.argv.slice(2));
226
+ }
227
+
228
+ /**
229
+ * Run the application with explicit argv.
230
+ *
231
+ * Useful for tests or manual programmatic invocation.
164
232
  */
165
- async run(argv: string[]): Promise<void> {
233
+ async runFromArgs(argv: string[]): Promise<void> {
234
+ // configure logger
235
+ AppContext.current.logger.onLogEvent((event) => {
236
+ process.stderr.write(event.message + "\n");
237
+ });
238
+
166
239
  try {
167
240
  // Parse global options first
168
241
  const { globalOptions, remainingArgs } = this.parseGlobalOptions(argv);
169
242
  this.applyGlobalOptions(globalOptions);
170
243
 
244
+ const mode = globalOptions["mode"] as ModeOptions ?? "default";
245
+ const resolvedMode = mode === "default" ? this.defaultMode : mode;
246
+
247
+ if (resolvedMode !== "cli") {
248
+ throw new Error(
249
+ `Mode '${resolvedMode}' is not supported by Application. Use TuiApplication or set --mode=cli.`
250
+ );
251
+ }
252
+
171
253
  // Extract command path from args
172
254
  const { commands: commandPath, remaining: flagArgs } = extractCommandChain(remainingArgs);
173
255
 
@@ -185,16 +267,18 @@ export class Application {
185
267
  }
186
268
 
187
269
  // Show help
188
- console.log(generateAppHelp(this.registry.list(), {
189
- appName: this.name,
190
- version: this.version,
191
- }));
192
- return;
270
+ const rootHelp = this.registry.get(KNOWN_COMMANDS.help);
271
+ if (rootHelp) {
272
+ await this.executeCommand(rootHelp, [], [KNOWN_COMMANDS.help]);
273
+ return;
274
+ }
275
+
276
+ throw new Error("Root help command not registered");
193
277
  }
194
278
 
195
279
  // Check for unknown command in path
196
- if (remainingPath.length > 0 && remainingPath[0] !== "help") {
197
- console.error(`Unknown command: ${remainingPath.join(" ")}`);
280
+ if (remainingPath.length > 0 && remainingPath[0] !== KNOWN_COMMANDS.help) {
281
+ AppContext.current.logger.error(`Unknown command: ${remainingPath.join(" ")}`);
198
282
  process.exitCode = 1;
199
283
  return;
200
284
  }
@@ -240,65 +324,44 @@ export class Application {
240
324
  const parseArgsConfig = schemaToParseArgsOptions(schema);
241
325
 
242
326
  let parsedValues: Record<string, unknown> = {};
243
- let parseError: string | undefined;
244
-
245
- try {
246
- const parseArgsOptions = {
247
- args: flagArgs,
248
- options: parseArgsConfig.options as ParseArgsConfig["options"],
249
- allowPositionals: false,
250
- strict: true, // Enable strict mode to catch unknown options
251
- };
252
- const result = parseArgs(parseArgsOptions);
253
- parsedValues = result.values;
254
- } catch (err) {
255
- // Capture parse error (e.g., unknown option)
256
- parseError = (err as Error).message;
257
- }
258
327
 
259
- // If there was a parse error, show it and help
260
- if (parseError) {
261
- console.error(`Error: ${parseError}\n`);
262
- console.log(generateCommandHelp(command, {
263
- appName: this.name,
264
- commandPath: commandPath.length > 0 ? commandPath : [command.name],
265
- }));
266
- process.exitCode = 1;
267
- return;
268
- }
328
+ const parseArgsOptions = {
329
+ args: flagArgs,
330
+ options: parseArgsConfig.options as ParseArgsConfig["options"],
331
+ allowNegative: true,
332
+ allowPositionals: false,
333
+ strict: false,
334
+ };
335
+
336
+ const result = parseArgs(parseArgsOptions);
337
+ parsedValues = result.values;
269
338
 
270
339
  let options;
271
340
  try {
272
341
  options = parseOptionValues(schema, parsedValues);
273
- } catch (err) {
274
- // Enum validation error from parseOptionValues
275
- console.error(`Error: ${(err as Error).message}\n`);
276
- console.log(generateCommandHelp(command, {
277
- appName: this.name,
278
- commandPath: commandPath.length > 0 ? commandPath : [command.name],
279
- }));
280
- process.exitCode = 1;
281
- return;
282
- }
342
+ } catch (err) {
343
+ // Enum validation error from parseOptionValues
344
+ AppContext.current.logger.error(`Error: ${(err as Error).message}\n`);
345
+ await this.printHelpForCommand(command, commandPath);
346
+ process.exitCode = 1;
347
+ return;
348
+ }
349
+
283
350
 
284
351
  // Validate options (required, min/max, etc.)
285
352
  const errors = validateOptions(schema, options);
286
353
  if (errors.length > 0) {
287
354
  for (const error of errors) {
288
- console.error(`Error: ${error.message}`);
355
+ AppContext.current.logger.error(`Error: ${error.message}`);
289
356
  }
290
- console.log(); // Blank line
291
- console.log(generateCommandHelp(command, {
292
- appName: this.name,
293
- commandPath: commandPath.length > 0 ? commandPath : [command.name],
294
- }));
357
+ await this.printHelpForCommand(command, commandPath);
295
358
  process.exitCode = 1;
296
359
  return;
297
360
  }
298
361
 
299
362
  // Call onBeforeRun hook
300
363
  if (this.hooks.onBeforeRun) {
301
- await this.hooks.onBeforeRun(this.context, command.name);
364
+ await this.hooks.onBeforeRun(command.name);
302
365
  }
303
366
 
304
367
  let error: Error | undefined;
@@ -306,20 +369,21 @@ export class Application {
306
369
  try {
307
370
  // Call beforeExecute hook on command
308
371
  if (command.beforeExecute) {
309
- await command.beforeExecute(this.context, options);
372
+ await command.beforeExecute(options);
310
373
  }
311
374
 
312
375
  // Build config if command implements buildConfig, otherwise pass options as-is
313
376
  let config: unknown;
314
377
  if (command.buildConfig) {
315
- config = await command.buildConfig(this.context, options);
378
+ config = await command.buildConfig(options);
316
379
  } else {
317
380
  config = options;
318
381
  }
319
382
 
320
383
  // Execute the command with the config
321
- const result = await command.execute(this.context, config);
322
-
384
+ const ctx: CommandExecutionContext = { signal: new AbortController().signal };
385
+ const result = await command.execute(config, ctx);
386
+
323
387
  // In CLI mode, handle result output
324
388
  if (mode === ExecutionMode.Cli && result) {
325
389
  const commandResult = result as CommandResult;
@@ -339,7 +403,7 @@ export class Application {
339
403
  // Always call afterExecute hook
340
404
  if (command.afterExecute) {
341
405
  try {
342
- await command.afterExecute(this.context, options, error);
406
+ await command.afterExecute(options, error);
343
407
  } catch (afterError) {
344
408
  // afterExecute error takes precedence if no prior error
345
409
  if (!error) {
@@ -351,7 +415,7 @@ export class Application {
351
415
 
352
416
  // Call onAfterRun hook
353
417
  if (this.hooks.onAfterRun) {
354
- await this.hooks.onAfterRun(this.context, command.name, error);
418
+ await this.hooks.onAfterRun(command.name, error);
355
419
  }
356
420
 
357
421
  // Re-throw if there was an error
@@ -360,6 +424,17 @@ export class Application {
360
424
  }
361
425
  }
362
426
 
427
+ private async printHelpForCommand(command: AnyCommand, commandPath: string[]): Promise<void> {
428
+ const resolvedCommandPath = commandPath.length > 0 ? commandPath : [command.name];
429
+
430
+ const helpCommand = command.subCommands?.find((sub) => sub.name === KNOWN_COMMANDS.help);
431
+ if (!helpCommand) {
432
+ throw new Error(`Help command not injected for '${resolvedCommandPath.join(" ")}'`);
433
+ }
434
+
435
+ await this.executeCommand(helpCommand, [], [...resolvedCommandPath, KNOWN_COMMANDS.help]);
436
+ }
437
+
363
438
  /**
364
439
  * Detect the execution mode based on command and args.
365
440
  */
@@ -377,43 +452,57 @@ export class Application {
377
452
  * Parse global options from argv.
378
453
  * Returns the parsed global options and remaining args.
379
454
  */
380
- private parseGlobalOptions(argv: string[]): {
455
+ protected parseGlobalOptions(argv: string[]): {
381
456
  globalOptions: GlobalOptions;
382
457
  remainingArgs: string[];
383
458
  } {
384
- const globalOptions: GlobalOptions = {};
459
+ const parseArgsConfig = schemaToParseArgsOptions(GLOBAL_OPTIONS_SCHEMA);
460
+
461
+ const result = parseArgs({
462
+ args: argv,
463
+ options: parseArgsConfig.options as ParseArgsConfig["options"],
464
+ allowPositionals: true,
465
+ allowNegative: true,
466
+ strict: false,
467
+ tokens: true,
468
+ });
469
+
470
+ const rawGlobalOptions = parseOptionValues(GLOBAL_OPTIONS_SCHEMA, result.values) as GlobalOptions;
471
+
472
+ const globalOptions: GlobalOptions = { ...rawGlobalOptions };
473
+
385
474
  const remainingArgs: string[] = [];
475
+ for (const token of result.tokens ?? []) {
476
+ if (token.kind === "positional") {
477
+ remainingArgs.push(token.value);
478
+ continue;
479
+ }
386
480
 
387
- let i = 0;
388
- while (i < argv.length) {
389
- const arg = argv[i]!;
390
-
391
- if (arg === "--log-level" && i + 1 < argv.length) {
392
- globalOptions["log-level"] = argv[i + 1];
393
- i += 2;
394
- } else if (arg.startsWith("--log-level=")) {
395
- globalOptions["log-level"] = arg.slice("--log-level=".length);
396
- i += 1;
397
- } else if (arg === "--detailed-logs") {
398
- globalOptions["detailed-logs"] = true;
399
- i += 1;
400
- } else if (arg === "--no-detailed-logs") {
401
- globalOptions["detailed-logs"] = false;
402
- i += 1;
403
- } else {
404
- remainingArgs.push(arg);
405
- i += 1;
481
+ if (token.kind === "option") {
482
+ const name = token.name;
483
+ if (name && !(name in GLOBAL_OPTIONS_SCHEMA)) {
484
+ remainingArgs.push(token.rawName);
485
+
486
+ if (token.value !== undefined) {
487
+ remainingArgs.push(String(token.value));
488
+ } else if (token.inlineValue !== undefined) {
489
+ remainingArgs.push(String((token as { inlineValue?: unknown }).inlineValue));
490
+ }
491
+ }
406
492
  }
407
493
  }
408
494
 
409
- return { globalOptions, remainingArgs };
495
+ return {
496
+ globalOptions,
497
+ remainingArgs,
498
+ };
410
499
  }
411
500
 
412
501
  /**
413
502
  * Apply global options to the application context.
414
503
  */
415
- private applyGlobalOptions(options: GlobalOptions): void {
416
- const logger = this.context.logger;
504
+ protected applyGlobalOptions(options: GlobalOptions): void {
505
+ const logger = AppContext.current.logger;
417
506
 
418
507
  // Apply detailed-logs
419
508
  if (options["detailed-logs"] !== undefined) {
@@ -438,24 +527,17 @@ export class Application {
438
527
  */
439
528
  private async handleError(error: Error): Promise<void> {
440
529
  if (this.hooks.onError) {
441
- await this.hooks.onError(this.context, error);
530
+ await this.hooks.onError(error);
442
531
  } else {
443
532
  // Default error handling
444
533
  if (error instanceof ConfigValidationError) {
445
534
  // Format validation errors more clearly
446
535
  const fieldInfo = error.field ? ` (${error.field})` : "";
447
- console.error(`Configuration error${fieldInfo}: ${error.message}`);
536
+ AppContext.current.logger.error(`Configuration error${fieldInfo}: ${error.message}`);
448
537
  } else {
449
- console.error(`Error: ${error.message}`);
538
+ AppContext.current.logger.error(`Error: ${error.message}`);
450
539
  }
451
540
  process.exitCode = 1;
452
541
  }
453
542
  }
454
-
455
- /**
456
- * Get the application context.
457
- */
458
- getContext(): AppContext {
459
- return this.context;
460
- }
461
543
  }