@pablozaiden/terminatui 0.1.2 → 0.3.0-beta-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.
Files changed (175) hide show
  1. package/AGENTS.md +43 -0
  2. package/CLAUDE.md +1 -0
  3. package/README.md +64 -43
  4. package/bun.lock +85 -0
  5. package/examples/tui-app/commands/config/app/get.ts +62 -0
  6. package/examples/tui-app/commands/config/app/index.ts +23 -0
  7. package/examples/tui-app/commands/config/app/set.ts +96 -0
  8. package/examples/tui-app/commands/config/index.ts +28 -0
  9. package/examples/tui-app/commands/config/user/get.ts +61 -0
  10. package/examples/tui-app/commands/config/user/index.ts +23 -0
  11. package/examples/tui-app/commands/config/user/set.ts +57 -0
  12. package/examples/tui-app/commands/greet.ts +14 -11
  13. package/examples/tui-app/commands/math.ts +6 -9
  14. package/examples/tui-app/commands/status.ts +24 -13
  15. package/examples/tui-app/index.ts +7 -3
  16. package/guides/01-hello-world.md +7 -2
  17. package/guides/02-adding-options.md +2 -2
  18. package/guides/03-multiple-commands.md +6 -8
  19. package/guides/04-subcommands.md +8 -8
  20. package/guides/05-interactive-tui.md +45 -30
  21. package/guides/06-config-validation.md +4 -12
  22. package/guides/07-async-cancellation.md +15 -69
  23. package/guides/08-complete-application.md +13 -179
  24. package/guides/README.md +7 -3
  25. package/package.json +4 -8
  26. package/src/__tests__/application.test.ts +87 -68
  27. package/src/__tests__/buildCliCommand.test.ts +99 -119
  28. package/src/__tests__/builtins.test.ts +27 -75
  29. package/src/__tests__/command.test.ts +100 -131
  30. package/src/__tests__/context.test.ts +1 -26
  31. package/src/__tests__/helpCore.test.ts +227 -0
  32. package/src/__tests__/parser.test.ts +98 -244
  33. package/src/__tests__/registry.test.ts +33 -160
  34. package/src/__tests__/schemaToFields.test.ts +75 -158
  35. package/src/builtins/help.ts +19 -4
  36. package/src/builtins/settings.ts +18 -32
  37. package/src/builtins/version.ts +4 -4
  38. package/src/cli/output/colors.ts +1 -1
  39. package/src/cli/parser.ts +26 -95
  40. package/src/core/application.ts +192 -110
  41. package/src/core/command.ts +26 -9
  42. package/src/core/context.ts +31 -20
  43. package/src/core/help.ts +24 -18
  44. package/src/core/knownCommands.ts +13 -0
  45. package/src/core/logger.ts +39 -42
  46. package/src/core/registry.ts +5 -12
  47. package/src/tui/TuiApplication.tsx +63 -120
  48. package/src/tui/TuiRoot.tsx +135 -0
  49. package/src/tui/adapters/factory.ts +19 -0
  50. package/src/tui/adapters/ink/InkRenderer.tsx +135 -0
  51. package/src/tui/adapters/ink/components/Button.tsx +12 -0
  52. package/src/tui/adapters/ink/components/Code.tsx +6 -0
  53. package/src/tui/adapters/ink/components/CodeHighlight.tsx +6 -0
  54. package/src/tui/adapters/ink/components/Container.tsx +5 -0
  55. package/src/tui/adapters/ink/components/Field.tsx +12 -0
  56. package/src/tui/adapters/ink/components/Label.tsx +24 -0
  57. package/src/tui/adapters/ink/components/MenuButton.tsx +12 -0
  58. package/src/tui/adapters/ink/components/MenuItem.tsx +17 -0
  59. package/src/tui/adapters/ink/components/Overlay.tsx +5 -0
  60. package/src/tui/adapters/ink/components/Panel.tsx +15 -0
  61. package/src/tui/adapters/ink/components/ScrollView.tsx +5 -0
  62. package/src/tui/adapters/ink/components/Select.tsx +44 -0
  63. package/src/tui/adapters/ink/components/Spacer.tsx +15 -0
  64. package/src/tui/adapters/ink/components/Spinner.tsx +5 -0
  65. package/src/tui/adapters/ink/components/TextInput.tsx +22 -0
  66. package/src/tui/adapters/ink/components/Value.tsx +7 -0
  67. package/src/tui/adapters/ink/keyboard.ts +97 -0
  68. package/src/tui/adapters/ink/utils.ts +16 -0
  69. package/src/tui/adapters/opentui/OpenTuiRenderer.tsx +115 -0
  70. package/src/tui/adapters/opentui/components/Button.tsx +13 -0
  71. package/src/tui/adapters/opentui/components/Code.tsx +12 -0
  72. package/src/tui/adapters/opentui/components/CodeHighlight.tsx +24 -0
  73. package/src/tui/adapters/opentui/components/Container.tsx +56 -0
  74. package/src/tui/adapters/opentui/components/Field.tsx +18 -0
  75. package/src/tui/adapters/opentui/components/Label.tsx +15 -0
  76. package/src/tui/adapters/opentui/components/MenuButton.tsx +14 -0
  77. package/src/tui/adapters/opentui/components/MenuItem.tsx +29 -0
  78. package/src/tui/adapters/opentui/components/Overlay.tsx +21 -0
  79. package/src/tui/adapters/opentui/components/Panel.tsx +78 -0
  80. package/src/tui/adapters/opentui/components/ScrollView.tsx +85 -0
  81. package/src/tui/adapters/opentui/components/Select.tsx +59 -0
  82. package/src/tui/adapters/opentui/components/Spacer.tsx +5 -0
  83. package/src/tui/adapters/opentui/components/Spinner.tsx +12 -0
  84. package/src/tui/adapters/opentui/components/TextInput.tsx +13 -0
  85. package/src/tui/adapters/opentui/components/Value.tsx +13 -0
  86. package/src/tui/{hooks → adapters/opentui/hooks}/useSpinner.ts +2 -11
  87. package/src/tui/adapters/opentui/keyboard.ts +61 -0
  88. package/src/tui/adapters/types.ts +70 -0
  89. package/src/tui/components/ActionButton.tsx +0 -36
  90. package/src/tui/components/CommandSelector.tsx +52 -92
  91. package/src/tui/components/ConfigForm.tsx +68 -42
  92. package/src/tui/components/FieldRow.tsx +0 -30
  93. package/src/tui/components/Header.tsx +14 -13
  94. package/src/tui/components/JsonHighlight.tsx +10 -17
  95. package/src/tui/components/ModalBase.tsx +38 -0
  96. package/src/tui/components/ResultsPanel.tsx +27 -36
  97. package/src/tui/components/StatusBar.tsx +24 -39
  98. package/src/tui/components/logColors.ts +12 -0
  99. package/src/tui/context/ClipboardContext.tsx +87 -0
  100. package/src/tui/context/ExecutorContext.tsx +139 -0
  101. package/src/tui/context/KeyboardContext.tsx +85 -71
  102. package/src/tui/context/LogsContext.tsx +35 -0
  103. package/src/tui/context/NavigationContext.tsx +194 -0
  104. package/src/tui/context/RendererContext.tsx +20 -0
  105. package/src/tui/context/TuiAppContext.tsx +58 -0
  106. package/src/tui/hooks/useActiveKeyHandler.ts +75 -0
  107. package/src/tui/hooks/useBackHandler.ts +34 -0
  108. package/src/tui/hooks/useClipboard.ts +40 -25
  109. package/src/tui/hooks/useClipboardProvider.ts +42 -0
  110. package/src/tui/hooks/useGlobalKeyHandler.ts +54 -0
  111. package/src/tui/modals/CliModal.tsx +82 -0
  112. package/src/tui/modals/EditorModal.tsx +207 -0
  113. package/src/tui/modals/LogsModal.tsx +98 -0
  114. package/src/tui/registry.ts +102 -0
  115. package/src/tui/screens/CommandSelectScreen.tsx +162 -0
  116. package/src/tui/screens/ConfigScreen.tsx +160 -0
  117. package/src/tui/screens/ErrorScreen.tsx +58 -0
  118. package/src/tui/screens/ResultsScreen.tsx +60 -0
  119. package/src/tui/screens/RunningScreen.tsx +72 -0
  120. package/src/tui/screens/ScreenBase.ts +6 -0
  121. package/src/tui/semantic/Button.tsx +7 -0
  122. package/src/tui/semantic/Code.tsx +7 -0
  123. package/src/tui/semantic/CodeHighlight.tsx +7 -0
  124. package/src/tui/semantic/Container.tsx +7 -0
  125. package/src/tui/semantic/Field.tsx +7 -0
  126. package/src/tui/semantic/Label.tsx +7 -0
  127. package/src/tui/semantic/MenuButton.tsx +7 -0
  128. package/src/tui/semantic/MenuItem.tsx +7 -0
  129. package/src/tui/semantic/Overlay.tsx +7 -0
  130. package/src/tui/semantic/Panel.tsx +7 -0
  131. package/src/tui/semantic/ScrollView.tsx +9 -0
  132. package/src/tui/semantic/Select.tsx +7 -0
  133. package/src/tui/semantic/Spacer.tsx +7 -0
  134. package/src/tui/semantic/Spinner.tsx +7 -0
  135. package/src/tui/semantic/TextInput.tsx +7 -0
  136. package/src/tui/semantic/Value.tsx +7 -0
  137. package/src/tui/semantic/types.ts +195 -0
  138. package/src/tui/theme.ts +25 -14
  139. package/src/tui/utils/buildCliCommand.ts +1 -0
  140. package/src/tui/utils/getEnumKeys.ts +3 -0
  141. package/src/tui/utils/parameterPersistence.ts +1 -0
  142. package/src/types/command.ts +0 -60
  143. package/examples/tui-app/commands/index.ts +0 -3
  144. package/src/__tests__/colors.test.ts +0 -127
  145. package/src/__tests__/commandClass.test.ts +0 -130
  146. package/src/__tests__/help.test.ts +0 -412
  147. package/src/__tests__/registryNew.test.ts +0 -160
  148. package/src/__tests__/table.test.ts +0 -146
  149. package/src/__tests__/tui.test.ts +0 -26
  150. package/src/builtins/index.ts +0 -4
  151. package/src/cli/help.ts +0 -174
  152. package/src/cli/index.ts +0 -3
  153. package/src/cli/output/index.ts +0 -2
  154. package/src/cli/output/table.ts +0 -141
  155. package/src/commands/help.ts +0 -50
  156. package/src/commands/index.ts +0 -1
  157. package/src/components/index.ts +0 -147
  158. package/src/core/index.ts +0 -15
  159. package/src/hooks/index.ts +0 -131
  160. package/src/index.ts +0 -137
  161. package/src/registry/commandRegistry.ts +0 -77
  162. package/src/registry/index.ts +0 -1
  163. package/src/tui/TuiApp.tsx +0 -582
  164. package/src/tui/app.ts +0 -29
  165. package/src/tui/components/CliModal.tsx +0 -81
  166. package/src/tui/components/EditorModal.tsx +0 -177
  167. package/src/tui/components/LogsPanel.tsx +0 -86
  168. package/src/tui/components/index.ts +0 -13
  169. package/src/tui/context/index.ts +0 -7
  170. package/src/tui/hooks/index.ts +0 -35
  171. package/src/tui/hooks/useKeyboardHandler.ts +0 -91
  172. package/src/tui/hooks/useLogStream.ts +0 -96
  173. package/src/tui/index.ts +0 -65
  174. package/src/tui/utils/index.ts +0 -13
  175. package/src/types/index.ts +0 -1
@@ -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
  }
@@ -1,5 +1,4 @@
1
1
  import type { ReactNode } from "react";
2
- import type { AppContext } from "./context.ts";
3
2
  import type { OptionSchema, OptionValues } from "../types/command.ts";
4
3
 
5
4
  /**
@@ -93,7 +92,7 @@ export type AnyCommand = Command<any, any>;
93
92
  * description = "Run the application";
94
93
  * options = runOptions;
95
94
  *
96
- * async buildConfig(ctx: AppContext, opts: OptionValues<typeof runOptions>): Promise<RunConfig> {
95
+ * async buildConfig(opts: OptionValues<typeof runOptions>): Promise<RunConfig> {
97
96
  * const repoPath = path.resolve(opts.repo);
98
97
  * if (!existsSync(repoPath)) {
99
98
  * throw new ConfigValidationError(`Repository not found: ${repoPath}`, "repo");
@@ -101,7 +100,7 @@ export type AnyCommand = Command<any, any>;
101
100
  * return { repoPath, iterations: parseInt(opts.iterations) };
102
101
  * }
103
102
  *
104
- * async execute(ctx: AppContext, config: RunConfig) {
103
+ * async execute(config: RunConfig) {
105
104
  * // config is already validated
106
105
  * return { success: true, data: result };
107
106
  * }
@@ -141,6 +140,9 @@ export abstract class Command<
141
140
  /** Whether this command runs immediately without config screen (like "check") */
142
141
  immediateExecution?: boolean;
143
142
 
143
+ /** If true, this command should not appear in the TUI command list. */
144
+ tuiHidden?: boolean;
145
+
144
146
  /**
145
147
  * Build and validate a configuration object from parsed options.
146
148
  *
@@ -153,24 +155,23 @@ export abstract class Command<
153
155
  * @throws ConfigValidationError if validation fails
154
156
  * @returns The validated configuration object
155
157
  */
156
- buildConfig?(ctx: AppContext, opts: OptionValues<TOptions>): Promise<TConfig> | TConfig;
158
+ buildConfig?(opts: OptionValues<TOptions>): Promise<TConfig> | TConfig;
157
159
 
158
160
  /**
159
161
  * Execute the command.
160
162
  * The framework will call this method for both CLI and TUI modes.
161
163
  *
162
- * @param ctx - Application context
163
164
  * @param config - The configuration object (from buildConfig, or raw options if buildConfig is not implemented)
164
165
  * @param execCtx - Execution context with abort signal for cancellation support
165
166
  * @returns Optional result for display in TUI results panel
166
167
  */
167
- abstract execute(ctx: AppContext, config: TConfig, execCtx?: CommandExecutionContext): Promise<CommandResult | void> | CommandResult | void;
168
+ abstract execute(config: TConfig, execCtx?: CommandExecutionContext): Promise<CommandResult | void> | CommandResult | void;
168
169
 
169
170
  /**
170
171
  * Called before buildConfig. Use for early validation, resource acquisition, etc.
171
172
  * If this throws, buildConfig and execute will not be called but afterExecute will still run.
172
173
  */
173
- beforeExecute?(ctx: AppContext, opts: OptionValues<TOptions>): Promise<void> | void;
174
+ beforeExecute?(opts: OptionValues<TOptions>): Promise<void> | void;
174
175
 
175
176
  /**
176
177
  * Called after execute, even if execute threw an error.
@@ -178,7 +179,6 @@ export abstract class Command<
178
179
  * @param error The error thrown by beforeExecute, buildConfig, or execute, if any
179
180
  */
180
181
  afterExecute?(
181
- ctx: AppContext,
182
182
  opts: OptionValues<TOptions>,
183
183
  error?: Error
184
184
  ): Promise<void> | void;
@@ -250,7 +250,24 @@ export abstract class Command<
250
250
  * Called by the framework during registration.
251
251
  */
252
252
  validate(): void {
253
- // No validation needed - execute is abstract and required
253
+ this.validateSubCommands();
254
+ }
255
+
256
+ private validateSubCommands(): void {
257
+ if (!this.subCommands || this.subCommands.length === 0) {
258
+ return;
259
+ }
260
+
261
+ const names = new Set<string>();
262
+ for (const subCommand of this.subCommands) {
263
+ if (names.has(subCommand.name)) {
264
+ throw new Error(
265
+ `Duplicate subcommand '${subCommand.name}' under '${this.name}'`
266
+ );
267
+ }
268
+ names.add(subCommand.name);
269
+ subCommand.validate();
270
+ }
254
271
  }
255
272
 
256
273
  /**
@@ -1,4 +1,5 @@
1
- import { Logger, createLogger, type LoggerConfig } from "./logger.ts";
1
+ import { type Logger, createLogger, type LoggerConfig, type LogEvent } from "./logger.ts";
2
+ import { appendFileSync } from "fs";
2
3
 
3
4
  /**
4
5
  * Application configuration stored in context.
@@ -8,10 +9,14 @@ export interface AppConfig {
8
9
  name: string;
9
10
  /** Application version */
10
11
  version: string;
12
+ /** Log target */
13
+ logTarget?: LogTarget[];
11
14
  /** Additional configuration values */
12
15
  [key: string]: unknown;
13
16
  }
14
17
 
18
+ export type LogTarget = "memory" | "file" | "none";
19
+
15
20
  /**
16
21
  * AppContext is the central container for application-wide services and state.
17
22
  * It holds the logger, configuration, and a generic service registry.
@@ -22,6 +27,7 @@ export interface AppConfig {
22
27
  export class AppContext {
23
28
  private static _current: AppContext | null = null;
24
29
  private readonly services = new Map<string, unknown>();
30
+ private readonly startTime = Date.now();
25
31
 
26
32
  /** The application logger */
27
33
  public readonly logger: Logger;
@@ -29,9 +35,27 @@ export class AppContext {
29
35
  /** The application configuration */
30
36
  public readonly config: AppConfig;
31
37
 
38
+ public logTarget: LogTarget[] = [];
39
+
40
+ public readonly logHistory: LogEvent[] = [];
41
+
32
42
  constructor(config: AppConfig, loggerConfig?: LoggerConfig) {
33
43
  this.config = config;
34
44
  this.logger = createLogger(loggerConfig);
45
+ this.logTarget = config.logTarget ?? ["none"];
46
+
47
+ this.logger.onLogEvent((event) => {
48
+ if (this.logTarget.includes("memory")) {
49
+ this.logHistory.push(event);
50
+ }
51
+
52
+ if (this.logTarget.includes("file")) {
53
+ const logFileName = `${this.config.name}-${this.startTime}.log`;
54
+ const logLine = event.message + "\n";
55
+
56
+ appendFileSync(logFileName, logLine);
57
+ }
58
+ });
35
59
  }
36
60
 
37
61
  /**
@@ -40,35 +64,22 @@ export class AppContext {
40
64
  */
41
65
  static get current(): AppContext {
42
66
  if (!AppContext._current) {
43
- throw new Error(
44
- "AppContext.current accessed before initialization. " +
45
- "Ensure Application.run() has been called."
46
- );
67
+ // return a fake context to avoid optional chaining everywhere
68
+ return new AppContext({ name: "unknown", version: "0.0.0" });
47
69
  }
48
70
  return AppContext._current;
49
71
  }
50
72
 
51
- /**
52
- * Check if a current context exists.
53
- */
54
- static hasCurrent(): boolean {
55
- return AppContext._current !== null;
56
- }
57
-
58
73
  /**
59
74
  * Set the current application context.
60
75
  * Called internally by Application.
61
76
  */
62
77
  static setCurrent(context: AppContext): void {
63
- AppContext._current = context;
64
- }
78
+ if (!context) {
79
+ throw new Error("Cannot set null or undefined context");
80
+ }
65
81
 
66
- /**
67
- * Clear the current context.
68
- * Useful for testing.
69
- */
70
- static clearCurrent(): void {
71
- AppContext._current = null;
82
+ AppContext._current = context;
72
83
  }
73
84
 
74
85
  /**