@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
@@ -1,125 +1,105 @@
1
- import { describe, test, expect } from "bun:test";
1
+ import { describe, expect, test } from "bun:test";
2
2
  import { buildCliCommand } from "../tui/utils/buildCliCommand.ts";
3
3
  import type { OptionSchema } from "../types/command.ts";
4
4
 
5
5
  describe("buildCliCommand", () => {
6
- test("builds command with no options", () => {
7
- const result = buildCliCommand("myapp", ["run"], {}, {});
8
- expect(result).toBe("myapp run");
9
- });
6
+ test("builds CLI commands from schema + values", () => {
7
+ const cases: Array<{
8
+ name: string;
9
+ commandPath: string[];
10
+ schema: OptionSchema;
11
+ values: Record<string, unknown>;
12
+ expected: string;
13
+ }> = [
14
+ {
15
+ name: "no options",
16
+ commandPath: ["run"],
17
+ schema: {},
18
+ values: {},
19
+ expected: "myapp run --mode cli",
20
+ },
21
+ {
22
+ name: "string option",
23
+ commandPath: ["greet"],
24
+ schema: { name: { type: "string", description: "Name" } },
25
+ values: { name: "John" },
26
+ expected: "myapp greet --name John --mode cli",
27
+ },
28
+ {
29
+ name: "quotes strings with spaces",
30
+ commandPath: ["greet"],
31
+ schema: { name: { type: "string", description: "Name" } },
32
+ values: { name: "John Doe" },
33
+ expected: "myapp greet --name \"John Doe\" --mode cli",
34
+ },
35
+ {
36
+ name: "skips empty/undefined/null",
37
+ commandPath: ["run"],
38
+ schema: {
39
+ name: { type: "string", description: "Name" },
40
+ count: { type: "number", description: "Count" },
41
+ },
42
+ values: { name: "", count: null },
43
+ expected: "myapp run --mode cli",
44
+ },
45
+ {
46
+ name: "boolean flags only when needed",
47
+ commandPath: ["run"],
48
+ schema: {
49
+ verbose: { type: "boolean", description: "Verbose" },
50
+ quiet: { type: "boolean", description: "Quiet" },
51
+ },
52
+ values: { verbose: true, quiet: false },
53
+ expected: "myapp run --verbose --mode cli",
54
+ },
55
+ {
56
+ name: "number values",
57
+ commandPath: ["process"],
58
+ schema: { count: { type: "number", description: "Count" } },
59
+ values: { count: 42 },
60
+ expected: "myapp process --count 42 --mode cli",
61
+ },
62
+ {
63
+ name: "array values",
64
+ commandPath: ["process"],
65
+ schema: { files: { type: "array", description: "Files" } },
66
+ values: { files: ["a.txt", "b.txt"] },
67
+ expected: "myapp process --files a.txt --files b.txt --mode cli",
68
+ },
69
+ {
70
+ name: "nested command path",
71
+ commandPath: ["db", "migrate"],
72
+ schema: { force: { type: "boolean", description: "Force" } },
73
+ values: { force: true },
74
+ expected: "myapp db migrate --force --mode cli",
75
+ },
76
+ {
77
+ name: "camelCase to kebab-case",
78
+ commandPath: ["build"],
79
+ schema: {
80
+ outputDir: { type: "string", description: "Output directory" },
81
+ maxRetries: { type: "number", description: "Max retries" },
82
+ },
83
+ values: { outputDir: "/tmp", maxRetries: 3 },
84
+ expected: "myapp build --output-dir /tmp --max-retries 3 --mode cli",
85
+ },
86
+ {
87
+ name: "skips defaults and uses --no- for booleans",
88
+ commandPath: ["run"],
89
+ schema: {
90
+ verbose: { type: "boolean", description: "Verbose", default: false },
91
+ count: { type: "number", description: "Count", default: 10 },
92
+ color: { type: "boolean", description: "Color output", default: true },
93
+ },
94
+ values: { verbose: false, count: 10, color: false },
95
+ expected: "myapp run --no-color --mode cli",
96
+ },
97
+ ];
10
98
 
11
- test("includes string options", () => {
12
- const schema: OptionSchema = {
13
- name: { type: "string", description: "Name" },
14
- };
15
- const values = { name: "John" };
16
-
17
- const result = buildCliCommand("myapp", ["greet"], schema, values);
18
- expect(result).toBe("myapp greet --name John");
19
- });
20
-
21
- test("quotes string values with spaces", () => {
22
- const schema: OptionSchema = {
23
- name: { type: "string", description: "Name" },
24
- };
25
- const values = { name: "John Doe" };
26
-
27
- const result = buildCliCommand("myapp", ["greet"], schema, values);
28
- expect(result).toBe("myapp greet --name \"John Doe\"");
29
- });
30
-
31
- test("excludes empty string values", () => {
32
- const schema: OptionSchema = {
33
- name: { type: "string", description: "Name" },
34
- };
35
- const values = { name: "" };
36
-
37
- const result = buildCliCommand("myapp", ["greet"], schema, values);
38
- expect(result).toBe("myapp greet");
39
- });
40
-
41
- test("includes boolean flags only when true", () => {
42
- const schema: OptionSchema = {
43
- verbose: { type: "boolean", description: "Verbose" },
44
- quiet: { type: "boolean", description: "Quiet" },
45
- };
46
- const values = { verbose: true, quiet: false };
47
-
48
- const result = buildCliCommand("myapp", ["run"], schema, values);
49
- expect(result).toBe("myapp run --verbose");
50
- });
51
-
52
- test("includes number values", () => {
53
- const schema: OptionSchema = {
54
- count: { type: "number", description: "Count" },
55
- };
56
- const values = { count: 42 };
57
-
58
- const result = buildCliCommand("myapp", ["process"], schema, values);
59
- expect(result).toBe("myapp process --count 42");
60
- });
61
-
62
- test("excludes undefined and null values", () => {
63
- const schema: OptionSchema = {
64
- name: { type: "string", description: "Name" },
65
- count: { type: "number", description: "Count" },
66
- };
67
- const values = { name: undefined, count: null };
68
-
69
- const result = buildCliCommand("myapp", ["run"], schema, values);
70
- expect(result).toBe("myapp run");
71
- });
72
-
73
- test("handles array values", () => {
74
- const schema: OptionSchema = {
75
- files: { type: "array", description: "Files" },
76
- };
77
- const values = { files: ["a.txt", "b.txt"] };
78
-
79
- const result = buildCliCommand("myapp", ["process"], schema, values);
80
- expect(result).toBe("myapp process --files a.txt --files b.txt");
81
- });
82
-
83
- test("handles nested command path", () => {
84
- const schema: OptionSchema = {
85
- force: { type: "boolean", description: "Force" },
86
- };
87
- const values = { force: true };
88
-
89
- const result = buildCliCommand("myapp", ["db", "migrate"], schema, values);
90
- expect(result).toBe("myapp db migrate --force");
91
- });
92
-
93
- test("converts camelCase to kebab-case", () => {
94
- const schema: OptionSchema = {
95
- outputDir: { type: "string", description: "Output directory" },
96
- maxRetries: { type: "number", description: "Max retries" },
97
- };
98
- const values = { outputDir: "/tmp", maxRetries: 3 };
99
-
100
- const result = buildCliCommand("myapp", ["build"], schema, values);
101
- expect(result).toContain("--output-dir /tmp");
102
- expect(result).toContain("--max-retries 3");
103
- });
104
-
105
- test("skips values that match defaults", () => {
106
- const schema: OptionSchema = {
107
- verbose: { type: "boolean", description: "Verbose", default: false },
108
- count: { type: "number", description: "Count", default: 10 },
109
- };
110
- const values = { verbose: false, count: 10 };
111
-
112
- const result = buildCliCommand("myapp", ["run"], schema, values);
113
- expect(result).toBe("myapp run");
114
- });
115
-
116
- test("uses --no-flag for false when default is true", () => {
117
- const schema: OptionSchema = {
118
- color: { type: "boolean", description: "Color output", default: true },
119
- };
120
- const values = { color: false };
121
-
122
- const result = buildCliCommand("myapp", ["run"], schema, values);
123
- expect(result).toBe("myapp run --no-color");
124
- });
99
+ for (const c of cases) {
100
+ expect(buildCliCommand("myapp", c.commandPath, c.schema, c.values), c.name).toBe(
101
+ c.expected
102
+ );
103
+ }
104
+ });
125
105
  });
@@ -1,7 +1,8 @@
1
- import { test, expect, describe, mock, beforeEach, afterEach } from "bun:test";
2
- import { createVersionCommand, formatVersion } from "../builtins/version.ts";
3
- import { createHelpCommand } from "../commands/help.ts";
4
- import { defineCommand } from "../types/command.ts";
1
+ import { test, expect, describe, beforeEach, afterEach } from "bun:test";
2
+ import { createVersionCommand } from "../builtins/version.ts";
3
+ import { createHelpCommandForParent, createRootHelpCommand } from "../builtins/help.ts";
4
+ import { Command } from "../core/command.ts";
5
+ import { KNOWN_COMMANDS } from "../core/knownCommands.ts";
5
6
 
6
7
  describe("Built-in Commands", () => {
7
8
  let originalLog: typeof console.log;
@@ -19,27 +20,6 @@ describe("Built-in Commands", () => {
19
20
  console.log = originalLog;
20
21
  });
21
22
 
22
- describe("formatVersion", () => {
23
- test("formats version with commit hash", () => {
24
- const result = formatVersion("1.0.0", "abc1234567890");
25
- expect(result).toBe("1.0.0 - abc1234");
26
- });
27
-
28
- test("formats version with short commit hash", () => {
29
- const result = formatVersion("1.0.0", "abc1234");
30
- expect(result).toBe("1.0.0 - abc1234");
31
- });
32
-
33
- test("shows (dev) when no commit hash", () => {
34
- const result = formatVersion("1.0.0");
35
- expect(result).toBe("1.0.0 - (dev)");
36
- });
37
-
38
- test("shows (dev) when commit hash is empty", () => {
39
- const result = formatVersion("1.0.0", "");
40
- expect(result).toBe("1.0.0 - (dev)");
41
- });
42
- });
43
23
 
44
24
  describe("VersionCommand", () => {
45
25
  test("creates command with name 'version'", () => {
@@ -70,64 +50,36 @@ describe("Built-in Commands", () => {
70
50
  });
71
51
  });
72
52
 
73
- describe("createHelpCommand", () => {
74
- const mockCommands = [
75
- defineCommand({
76
- name: "run",
77
- description: "Run something",
78
- execute: () => {},
79
- }),
80
- defineCommand({
81
- name: "build",
82
- description: "Build something",
83
- execute: () => {},
84
- }),
85
- ];
86
-
87
- test("creates command with name 'help'", () => {
88
- const cmd = createHelpCommand({ getCommands: () => mockCommands });
89
- expect(cmd.name).toBe("help");
53
+ describe("HelpCommand", () => {
54
+ test("createRootHelpCommand creates command with name 'help'", () => {
55
+ const cmd = createRootHelpCommand([], "myapp", "1.0.0");
56
+ expect(cmd.name).toBe(KNOWN_COMMANDS.help);
90
57
  });
91
58
 
92
- test("has description", () => {
93
- const cmd = createHelpCommand({ getCommands: () => mockCommands });
94
- expect(cmd.description).toBeDefined();
59
+ test("is hidden in TUI", () => {
60
+ const cmd = createRootHelpCommand([], "myapp", "1.0.0");
61
+ expect(cmd.tuiHidden).toBe(true);
62
+ expect(cmd.supportsTui()).toBe(false);
95
63
  });
96
64
 
97
- test("has aliases including --help", () => {
98
- const cmd = createHelpCommand({ getCommands: () => mockCommands });
99
- expect(cmd.aliases).toContain("--help");
65
+ test("execute prints app help", async () => {
66
+ const cmd = createRootHelpCommand([], "myapp", "1.0.0");
67
+ await cmd.execute();
68
+ expect(logOutput.join("\n")).toContain("myapp");
100
69
  });
101
70
 
102
- test("is hidden by default", () => {
103
- const cmd = createHelpCommand({ getCommands: () => mockCommands });
104
- expect(cmd.hidden).toBe(true);
105
- });
71
+ test("createHelpCommandForParent prints parent help", async () => {
72
+ class ParentCommand extends Command {
73
+ readonly name = "run";
74
+ readonly description = "Run something";
75
+ readonly options = {} as const;
106
76
 
107
- test("has command option", () => {
108
- const cmd = createHelpCommand({ getCommands: () => mockCommands });
109
- expect(cmd.options?.["command"]).toBeDefined();
110
- });
111
-
112
- test("execute calls getCommands", () => {
113
- const getCommands = mock(() => mockCommands);
114
- const cmd = createHelpCommand({ getCommands });
115
- // @ts-expect-error - testing with partial options
116
- cmd.execute({ options: {}, args: [], commandPath: ["help"] });
117
- expect(getCommands).toHaveBeenCalled();
118
- });
77
+ override async execute(): Promise<void> {}
78
+ }
119
79
 
120
- test("shows help for specific command when provided", () => {
121
- const cmd = createHelpCommand({
122
- getCommands: () => mockCommands,
123
- appName: "myapp",
124
- });
125
- cmd.execute({
126
- options: { command: "run" },
127
- args: [],
128
- commandPath: ["help"],
129
- });
130
- expect(logOutput.join("")).toContain("run");
80
+ const cmd = createHelpCommandForParent(new ParentCommand(), "myapp", "1.0.0");
81
+ await cmd.execute();
82
+ expect(logOutput.join("\n")).toContain("run");
131
83
  });
132
84
  });
133
85
  });
@@ -1,157 +1,126 @@
1
1
  import { test, expect, describe } from "bun:test";
2
- import { defineCommand } from "../types/command.ts";
3
-
4
- describe("defineCommand", () => {
5
- test("creates a command with name and description", () => {
6
- const cmd = defineCommand({
7
- name: "test",
8
- description: "A test command",
9
- execute: () => {},
10
- });
11
-
2
+ import { Command } from "../core/command.ts";
3
+ import type { OptionSchema, OptionValues } from "../types/command.ts";
4
+
5
+ const testOptions = {
6
+ verbose: {
7
+ type: "boolean",
8
+ description: "Enable verbose output",
9
+ alias: "v",
10
+ },
11
+ name: {
12
+ type: "string",
13
+ description: "Name option",
14
+ },
15
+ } as const satisfies OptionSchema;
16
+
17
+ describe("Command (class-based)", () => {
18
+ test("has name and description", () => {
19
+ class TestCommand extends Command<typeof testOptions> {
20
+ readonly name = "test";
21
+ readonly description = "A test command";
22
+ readonly options = testOptions;
23
+
24
+ override async execute(): Promise<void> {}
25
+ }
26
+
27
+ const cmd = new TestCommand();
12
28
  expect(cmd.name).toBe("test");
13
29
  expect(cmd.description).toBe("A test command");
14
30
  });
15
31
 
16
- test("creates a command with options", () => {
17
- const cmd = defineCommand({
18
- name: "test",
19
- description: "A test command",
20
- options: {
21
- verbose: {
22
- type: "boolean",
23
- description: "Enable verbose output",
24
- alias: "v",
25
- },
26
- },
27
- execute: () => {},
28
- });
29
-
30
- expect(cmd.options?.["verbose"]).toBeDefined();
31
- expect(cmd.options?.["verbose"]?.type).toBe("boolean");
32
- });
32
+ test("has options", () => {
33
+ class TestCommand extends Command<typeof testOptions> {
34
+ readonly name = "test";
35
+ readonly description = "A test command";
36
+ readonly options = testOptions;
33
37
 
34
- test("creates a command with aliases", () => {
35
- const cmd = defineCommand({
36
- name: "test",
37
- description: "A test command",
38
- aliases: ["t", "tst"],
39
- execute: () => {},
40
- });
38
+ override async execute(): Promise<void> {}
39
+ }
41
40
 
42
- expect(cmd.aliases).toEqual(["t", "tst"]);
41
+ const cmd = new TestCommand();
42
+ expect(cmd.options["verbose"]?.type).toBe("boolean");
43
43
  });
44
44
 
45
- test("creates a command with subcommands", () => {
46
- const sub = defineCommand({
47
- name: "sub",
48
- description: "A subcommand",
49
- execute: () => {},
50
- });
51
-
52
- const cmd = defineCommand({
53
- name: "parent",
54
- description: "A parent command",
55
- subcommands: { sub },
56
- execute: () => {},
57
- });
58
-
59
- expect(cmd.subcommands?.["sub"]).toBe(sub);
60
- });
45
+ test("supports subcommands", () => {
46
+ class SubCommand extends Command<OptionSchema> {
47
+ readonly name = "sub";
48
+ readonly description = "A subcommand";
49
+ readonly options = {} as const;
61
50
 
62
- test("executes sync command", () => {
63
- let executed = false;
64
- const cmd = defineCommand({
65
- name: "test",
66
- description: "A test command",
67
- execute: () => {
68
- executed = true;
69
- },
70
- });
71
-
72
- cmd.execute({ options: {}, args: [], commandPath: ["test"] });
73
- expect(executed).toBe(true);
74
- });
51
+ override async execute(): Promise<void> {}
52
+ }
75
53
 
76
- test("executes async command", async () => {
77
- let executed = false;
78
- const cmd = defineCommand({
79
- name: "test",
80
- description: "A test command",
81
- execute: async () => {
82
- await new Promise((r) => setTimeout(r, 10));
83
- executed = true;
84
- },
85
- });
86
-
87
- await cmd.execute({ options: {}, args: [], commandPath: ["test"] });
88
- expect(executed).toBe(true);
89
- });
54
+ class ParentCommand extends Command<OptionSchema> {
55
+ readonly name = "parent";
56
+ readonly description = "A parent command";
57
+ readonly options = {} as const;
58
+ override subCommands = [new SubCommand()];
59
+
60
+ override async execute(): Promise<void> {}
61
+ }
90
62
 
91
- test("passes options to execute", () => {
92
- let receivedOptions: unknown;
93
- const cmd = defineCommand({
94
- name: "test",
95
- description: "A test command",
96
- options: {
97
- name: {
98
- type: "string",
99
- description: "Name option",
100
- },
101
- },
102
- execute: (ctx) => {
103
- receivedOptions = ctx.options;
104
- },
105
- });
106
-
107
- cmd.execute({ options: { name: "world" }, args: [], commandPath: ["test"] });
108
- expect(receivedOptions).toEqual({ name: "world" });
63
+ const cmd = new ParentCommand();
64
+ expect(cmd.getSubCommand("sub")?.name).toBe("sub");
109
65
  });
110
66
 
111
- test("supports beforeExecute hook", async () => {
112
- const order: string[] = [];
113
- const cmd = defineCommand({
114
- name: "test",
115
- description: "A test command",
116
- beforeExecute: () => {
117
- order.push("before");
118
- },
119
- execute: () => {
120
- order.push("execute");
121
- },
122
- });
67
+ test("executes command", async () => {
68
+ class ExecCommand extends Command<typeof testOptions> {
69
+ readonly name = "exec";
70
+ readonly description = "Exec command";
71
+ readonly options = testOptions;
123
72
 
124
- await cmd.beforeExecute?.({ options: {}, args: [], commandPath: ["test"] });
125
- await cmd.execute({ options: {}, args: [], commandPath: ["test"] });
126
- expect(order).toEqual(["before", "execute"]);
73
+ executedWith: OptionValues<typeof testOptions> | null = null;
74
+
75
+ override async execute(opts: OptionValues<typeof testOptions>): Promise<void> {
76
+ this.executedWith = opts;
77
+ }
78
+ }
79
+
80
+ const cmd = new ExecCommand();
81
+ await cmd.execute({ verbose: true, name: "world" });
82
+ expect(cmd.executedWith).toEqual({ verbose: true, name: "world" });
127
83
  });
128
84
 
129
- test("supports afterExecute hook", async () => {
85
+ test("beforeExecute/afterExecute hooks are callable", () => {
130
86
  const order: string[] = [];
131
- const cmd = defineCommand({
132
- name: "test",
133
- description: "A test command",
134
- execute: () => {
87
+
88
+ class HookCommand extends Command<OptionSchema> {
89
+ readonly name = "hook";
90
+ readonly description = "Hook command";
91
+ readonly options = {} as const;
92
+
93
+ override beforeExecute(): void {
94
+ order.push("before");
95
+ }
96
+
97
+ override execute(): void {
135
98
  order.push("execute");
136
- },
137
- afterExecute: () => {
99
+ }
100
+
101
+ override afterExecute(): void {
138
102
  order.push("after");
139
- },
140
- });
103
+ }
104
+ }
141
105
 
142
- await cmd.execute({ options: {}, args: [], commandPath: ["test"] });
143
- await cmd.afterExecute?.({ options: {}, args: [], commandPath: ["test"] });
144
- expect(order).toEqual(["execute", "after"]);
106
+ const cmd = new HookCommand();
107
+ cmd.beforeExecute();
108
+ cmd.execute();
109
+ cmd.afterExecute();
110
+
111
+ expect(order).toEqual(["before", "execute", "after"]);
145
112
  });
146
113
 
147
- test("supports hidden commands", () => {
148
- const cmd = defineCommand({
149
- name: "hidden",
150
- description: "A hidden command",
151
- hidden: true,
152
- execute: () => {},
153
- });
114
+ test("supports tuiHidden", () => {
115
+ class HiddenCommand extends Command<OptionSchema> {
116
+ readonly name = "hidden";
117
+ readonly description = "Hidden";
118
+ readonly options = {} as const;
119
+ override readonly tuiHidden = true;
120
+
121
+ override async execute(): Promise<void> {}
122
+ }
154
123
 
155
- expect(cmd.hidden).toBe(true);
124
+ expect(new HiddenCommand().tuiHidden).toBe(true);
156
125
  });
157
126
  });
@@ -0,0 +1,63 @@
1
+ import { test, expect } from "bun:test";
2
+ import { Command } from "../core/command.ts";
3
+ import type { OptionSchema } from "../types/command.ts";
4
+
5
+ class TestCommand extends Command<typeof TestCommand.Options> {
6
+ static readonly Options = {
7
+ a: { type: "string" as const, description: "a" },
8
+ b: { type: "string" as const, description: "b" },
9
+ } as const satisfies OptionSchema;
10
+
11
+ readonly name = "my";
12
+ readonly description = "my";
13
+ readonly options = TestCommand.Options;
14
+
15
+ public readonly onChangeCalls: Array<[
16
+ string,
17
+ unknown,
18
+ Record<string, unknown>
19
+ ]> = [];
20
+
21
+ override execute(): void {}
22
+
23
+ override onConfigChange(
24
+ key: string,
25
+ value: unknown,
26
+ allValues: Record<string, unknown>
27
+ ) {
28
+ this.onChangeCalls.push([key, value, allValues]);
29
+ if (key === "a") {
30
+ return { b: "derived" };
31
+ }
32
+ return undefined;
33
+ }
34
+
35
+ applyTuiConfigChange(
36
+ key: string,
37
+ value: unknown,
38
+ values: Record<string, unknown>
39
+ ): Record<string, unknown> {
40
+ let nextValues: Record<string, unknown> = { ...values, [key]: value };
41
+
42
+ const updates = this.onConfigChange?.(key, value, nextValues);
43
+ if (updates && typeof updates === "object") {
44
+ nextValues = { ...nextValues, ...updates };
45
+ }
46
+
47
+ return nextValues;
48
+ }
49
+ }
50
+
51
+ test("onConfigChange merges returned updates", () => {
52
+ const command = new TestCommand();
53
+
54
+ const next = command.applyTuiConfigChange("a", "new", {
55
+ a: "old",
56
+ b: "oldb",
57
+ });
58
+
59
+ expect(command.onChangeCalls.length).toBe(1);
60
+ expect(command.onChangeCalls[0]?.[0]).toBe("a");
61
+ expect(command.onChangeCalls[0]?.[1]).toBe("new");
62
+ expect(next).toEqual({ a: "new", b: "derived" });
63
+ });