@reliverse/rempts-core 1.6.1 → 2.3.2

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 (155) hide show
  1. package/README.md +398 -102
  2. package/dist/cli.d.ts +32 -0
  3. package/dist/cli.js +731 -0
  4. package/dist/config-loader.d.ts +42 -0
  5. package/dist/config-loader.js +20 -0
  6. package/dist/config.d.ts +99 -0
  7. package/dist/config.js +188 -0
  8. package/dist/file-loader.d.ts +43 -0
  9. package/dist/file-loader.js +199 -0
  10. package/dist/global-flags.d.ts +36 -0
  11. package/dist/global-flags.js +36 -0
  12. package/dist/mod.d.ts +13 -0
  13. package/dist/mod.js +19 -0
  14. package/dist/parser.d.ts +6 -0
  15. package/dist/parser.js +137 -0
  16. package/dist/plugin/context.d.ts +13 -0
  17. package/dist/plugin/context.js +53 -0
  18. package/dist/plugin/create.d.ts +92 -0
  19. package/dist/plugin/create.js +61 -0
  20. package/dist/plugin/loader.d.ts +12 -0
  21. package/dist/plugin/loader.js +65 -0
  22. package/dist/plugin/manager.d.ts +53 -0
  23. package/dist/plugin/manager.js +135 -0
  24. package/dist/plugin/mod.d.ts +10 -0
  25. package/dist/plugin/mod.js +27 -0
  26. package/dist/plugin/store.d.ts +45 -0
  27. package/dist/plugin/store.js +60 -0
  28. package/dist/plugin/testing.d.ts +38 -0
  29. package/dist/plugin/testing.js +175 -0
  30. package/dist/plugin/types.d.ts +146 -0
  31. package/dist/tui/registry.d.ts +8 -0
  32. package/dist/tui/registry.js +10 -0
  33. package/dist/tui/types.d.ts +58 -0
  34. package/dist/tui/types.js +10 -0
  35. package/dist/types.d.ts +178 -0
  36. package/dist/types.js +25 -0
  37. package/dist/utils/logger.d.ts +10 -0
  38. package/dist/utils/logger.js +27 -0
  39. package/dist/utils/merge.d.ts +13 -0
  40. package/dist/utils/merge.js +25 -0
  41. package/dist/utils/mod.d.ts +6 -0
  42. package/dist/utils/mod.js +2 -0
  43. package/dist/utils/type-helpers.d.ts +41 -0
  44. package/dist/utils/type-helpers.js +0 -0
  45. package/dist/validation.d.ts +30 -0
  46. package/dist/validation.js +121 -0
  47. package/package.json +47 -44
  48. package/src/cli.ts +1049 -0
  49. package/src/config-loader.ts +71 -0
  50. package/src/config.ts +270 -0
  51. package/src/file-loader.ts +346 -0
  52. package/src/global-flags.ts +50 -0
  53. package/src/mod.ts +74 -0
  54. package/src/parser.ts +212 -0
  55. package/src/plugin/context.ts +88 -0
  56. package/src/plugin/create.ts +174 -0
  57. package/src/plugin/loader.ts +111 -0
  58. package/src/plugin/manager.ts +244 -0
  59. package/src/plugin/mod.ts +51 -0
  60. package/src/plugin/store.ts +124 -0
  61. package/src/plugin/testing.ts +236 -0
  62. package/src/plugin/types.ts +206 -0
  63. package/src/tui/registry.ts +22 -0
  64. package/src/tui/types.ts +79 -0
  65. package/src/types.ts +285 -0
  66. package/src/utils/logger.ts +43 -0
  67. package/src/utils/merge.ts +54 -0
  68. package/src/utils/mod.ts +7 -0
  69. package/src/utils/type-helpers.ts +151 -0
  70. package/src/validation.ts +177 -0
  71. package/LICENSE +0 -21
  72. package/bin/core-impl/anykey/anykey-mod.d.ts +0 -12
  73. package/bin/core-impl/anykey/anykey-mod.js +0 -125
  74. package/bin/core-impl/date/date.d.ts +0 -2
  75. package/bin/core-impl/date/date.js +0 -236
  76. package/bin/core-impl/editor/editor-mod.d.ts +0 -25
  77. package/bin/core-impl/editor/editor-mod.js +0 -896
  78. package/bin/core-impl/figures/figures-mod.d.ts +0 -233
  79. package/bin/core-impl/figures/figures-mod.js +0 -286
  80. package/bin/core-impl/figures/figures.test.d.ts +0 -1
  81. package/bin/core-impl/figures/figures.test.js +0 -474
  82. package/bin/core-impl/input/confirm-prompt.d.ts +0 -5
  83. package/bin/core-impl/input/confirm-prompt.js +0 -173
  84. package/bin/core-impl/input/input-prompt.d.ts +0 -16
  85. package/bin/core-impl/input/input-prompt.js +0 -370
  86. package/bin/core-impl/launcher/_parser.d.ts +0 -2
  87. package/bin/core-impl/launcher/_parser.js +0 -122
  88. package/bin/core-impl/launcher/_utils.d.ts +0 -8
  89. package/bin/core-impl/launcher/_utils.js +0 -29
  90. package/bin/core-impl/launcher/args.d.ts +0 -3
  91. package/bin/core-impl/launcher/args.js +0 -89
  92. package/bin/core-impl/launcher/command.d.ts +0 -8
  93. package/bin/core-impl/launcher/command.js +0 -68
  94. package/bin/core-impl/launcher/launcher-mod.d.ts +0 -8
  95. package/bin/core-impl/launcher/launcher-mod.js +0 -34
  96. package/bin/core-impl/launcher/usage.d.ts +0 -3
  97. package/bin/core-impl/launcher/usage.js +0 -104
  98. package/bin/core-impl/msg-fmt/colors.d.ts +0 -30
  99. package/bin/core-impl/msg-fmt/colors.js +0 -42
  100. package/bin/core-impl/msg-fmt/logger.d.ts +0 -17
  101. package/bin/core-impl/msg-fmt/logger.js +0 -106
  102. package/bin/core-impl/msg-fmt/mapping.d.ts +0 -3
  103. package/bin/core-impl/msg-fmt/mapping.js +0 -49
  104. package/bin/core-impl/msg-fmt/messages.d.ts +0 -35
  105. package/bin/core-impl/msg-fmt/messages.js +0 -314
  106. package/bin/core-impl/msg-fmt/terminal.d.ts +0 -15
  107. package/bin/core-impl/msg-fmt/terminal.js +0 -59
  108. package/bin/core-impl/msg-fmt/variants.d.ts +0 -11
  109. package/bin/core-impl/msg-fmt/variants.js +0 -52
  110. package/bin/core-impl/next-steps/next-steps.d.ts +0 -14
  111. package/bin/core-impl/next-steps/next-steps.js +0 -24
  112. package/bin/core-impl/number/number-mod.d.ts +0 -28
  113. package/bin/core-impl/number/number-mod.js +0 -197
  114. package/bin/core-impl/results/results.d.ts +0 -7
  115. package/bin/core-impl/results/results.js +0 -27
  116. package/bin/core-impl/select/multiselect-prompt.d.ts +0 -2
  117. package/bin/core-impl/select/multiselect-prompt.js +0 -341
  118. package/bin/core-impl/select/nummultiselect-prompt.d.ts +0 -6
  119. package/bin/core-impl/select/nummultiselect-prompt.js +0 -105
  120. package/bin/core-impl/select/numselect-prompt.d.ts +0 -7
  121. package/bin/core-impl/select/numselect-prompt.js +0 -115
  122. package/bin/core-impl/select/select-prompt.d.ts +0 -33
  123. package/bin/core-impl/select/select-prompt.js +0 -302
  124. package/bin/core-impl/select/toggle-prompt.d.ts +0 -5
  125. package/bin/core-impl/select/toggle-prompt.js +0 -208
  126. package/bin/core-impl/st-end/end.d.ts +0 -2
  127. package/bin/core-impl/st-end/end.js +0 -42
  128. package/bin/core-impl/st-end/start.d.ts +0 -17
  129. package/bin/core-impl/st-end/start.js +0 -66
  130. package/bin/core-impl/task/progress.d.ts +0 -2
  131. package/bin/core-impl/task/progress.js +0 -57
  132. package/bin/core-impl/task/spinner.d.ts +0 -15
  133. package/bin/core-impl/task/spinner.js +0 -110
  134. package/bin/core-impl/utils/colorize.d.ts +0 -2
  135. package/bin/core-impl/utils/colorize.js +0 -134
  136. package/bin/core-impl/utils/errors.d.ts +0 -1
  137. package/bin/core-impl/utils/errors.js +0 -15
  138. package/bin/core-impl/utils/prevent.d.ts +0 -10
  139. package/bin/core-impl/utils/prevent.js +0 -69
  140. package/bin/core-impl/utils/prompt-end.d.ts +0 -8
  141. package/bin/core-impl/utils/prompt-end.js +0 -33
  142. package/bin/core-impl/utils/stream-text.d.ts +0 -18
  143. package/bin/core-impl/utils/stream-text.js +0 -136
  144. package/bin/core-impl/utils/system.d.ts +0 -6
  145. package/bin/core-impl/utils/system.js +0 -7
  146. package/bin/core-impl/utils/validate.d.ts +0 -22
  147. package/bin/core-impl/utils/validate.js +0 -17
  148. package/bin/core-impl/visual/animate/animate.d.ts +0 -14
  149. package/bin/core-impl/visual/animate/animate.js +0 -64
  150. package/bin/core-impl/visual/ascii-art/ascii-art.d.ts +0 -6
  151. package/bin/core-impl/visual/ascii-art/ascii-art.js +0 -12
  152. package/bin/core-types.d.ts +0 -434
  153. package/bin/main.d.ts +0 -41
  154. package/bin/main.js +0 -96
  155. /package/{bin/core-types.js → dist/plugin/types.js} +0 -0
@@ -0,0 +1,71 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { remptsConfigSchema } from "./config";
4
+
5
+ // Type for loaded config with defaults applied by Zod
6
+ export interface LoadedConfig {
7
+ name?: string;
8
+ version?: string;
9
+ description?: string;
10
+ commands?: {
11
+ manifest?: string;
12
+ directory?: string;
13
+ generateReport?: boolean;
14
+ };
15
+ // Zod applies defaults, so these objects are never undefined
16
+ build: {
17
+ entry?: string | string[];
18
+ outdir?: string;
19
+ targets: string[]; // Always has default ['native']
20
+ compress: boolean; // Always has default false
21
+ minify: boolean; // Always has default false
22
+ external?: string[];
23
+ sourcemap: boolean; // Always has default true
24
+ };
25
+ dev: {
26
+ watch: boolean; // Always has default true
27
+ inspect: boolean; // Always has default false
28
+ port?: number;
29
+ };
30
+ test: {
31
+ pattern: string | string[]; // Always has default ['**/*.test.ts', '**/*.spec.ts']
32
+ coverage: boolean; // Always has default false
33
+ watch: boolean; // Always has default false
34
+ };
35
+ workspace: {
36
+ packages?: string[];
37
+ shared?: any;
38
+ versionStrategy: "fixed" | "independent"; // Always has default 'fixed'
39
+ };
40
+ release: {
41
+ npm: boolean; // Always has default true
42
+ github: boolean; // Always has default false
43
+ tagFormat: string; // Always has default 'v{{version}}'
44
+ conventionalCommits: boolean; // Always has default true
45
+ };
46
+ plugins: any[]; // Always has default []
47
+ }
48
+
49
+ // Config file names to search for
50
+ const CONFIG_NAMES = ["dler.config.ts", "dler.config.js", "dler.config.mjs"];
51
+
52
+ export async function loadConfig(cwd = process.cwd()): Promise<LoadedConfig> {
53
+ // Look for config file
54
+ for (const configName of CONFIG_NAMES) {
55
+ const configPath = path.join(cwd, configName);
56
+ if (existsSync(configPath)) {
57
+ try {
58
+ const module = await import(configPath);
59
+ // Arktype assert automatically applies all defaults and validates
60
+ const config = remptsConfigSchema.assert(module.default || module) as LoadedConfig;
61
+ return config;
62
+ } catch (error) {
63
+ console.error(`Error loading config from ${configPath}:`, error);
64
+ throw error;
65
+ }
66
+ }
67
+ }
68
+
69
+ // Throw error if no config file found
70
+ throw new Error(`No configuration file found. Please create one of: ${CONFIG_NAMES.join(", ")}`);
71
+ }
package/src/config.ts ADDED
@@ -0,0 +1,270 @@
1
+ import { type } from "arktype";
2
+
3
+ /**
4
+ * Valid workspace version strategies
5
+ */
6
+ const VersionStrategy = type("'fixed'|'independent'");
7
+
8
+ /**
9
+ * Valid port range with descriptive error messages
10
+ */
11
+ const PortNumber = type("number.integer > 0 & number.integer < 65536")
12
+ .configure({
13
+ description: "a valid port number",
14
+ })
15
+ .narrow((n: number) => {
16
+ // Check for commonly problematic ports
17
+ const reservedPorts = [22, 80, 443, 3306, 5432]; // SSH, HTTP, HTTPS, MySQL, PostgreSQL
18
+ if (reservedPorts.includes(n)) {
19
+ // Note: ctx.warn is not available in current arktype version
20
+ // This is just informational for future enhancement
21
+ console.warn(`Port ${n} is commonly used by system services and may cause conflicts`);
22
+ }
23
+ return true;
24
+ });
25
+
26
+ /**
27
+ * File path validation with basic path safety checks
28
+ */
29
+ const SafePath = type("string")
30
+ .configure({
31
+ description: "a valid file path",
32
+ })
33
+ .narrow((path: string, ctx) => {
34
+ if (!path.trim()) {
35
+ return ctx.reject("path cannot be empty or only whitespace");
36
+ }
37
+ // Basic path traversal protection
38
+ if (path.includes("../") || path.includes("..\\")) {
39
+ // Note: ctx.warn is not available in current arktype version
40
+ console.warn("path contains directory traversal (..) which may be unsafe");
41
+ }
42
+ return true;
43
+ });
44
+
45
+ /**
46
+ * Build targets validation
47
+ */
48
+ const BuildTargets = type("string[]")
49
+ .configure({
50
+ description: "an array of valid build targets",
51
+ })
52
+ .narrow((targets: string[], ctx) => {
53
+ const validTargets = [
54
+ "darwin-arm64",
55
+ "darwin-x64",
56
+ "linux-arm64",
57
+ "linux-x64",
58
+ "windows-x64",
59
+ "bun-linux-x64-modern",
60
+ "bun-darwin-x64-modern",
61
+ "bun-windows-x64-modern",
62
+ ];
63
+
64
+ const invalidTargets = targets.filter((target) => !validTargets.includes(target));
65
+ if (invalidTargets.length > 0) {
66
+ return ctx.reject({
67
+ expected: `valid build targets: ${validTargets.join(", ")}`,
68
+ actual: `invalid targets: ${invalidTargets.join(", ")}`,
69
+ });
70
+ }
71
+ return true;
72
+ });
73
+
74
+ /**
75
+ * Comprehensive Rempts configuration schema with enhanced validation
76
+ * Codegen and TypeScript are REQUIRED for all Rempts projects
77
+ */
78
+ export const remptsConfigSchema = type({
79
+ // Base configuration (required for CLI creation, optional for partial configs)
80
+ "name?": type("string")
81
+ .configure({
82
+ description: "package name (npm naming conventions)",
83
+ })
84
+ .narrow((name: string, ctx) => {
85
+ if (name.length < 1) {
86
+ return ctx.reject("package name cannot be empty");
87
+ }
88
+ if (name.length > 214) {
89
+ return ctx.reject("package name too long (max 214 characters)");
90
+ }
91
+ return true;
92
+ }),
93
+ "version?": type("string.semver").configure({
94
+ description: "semantic version string",
95
+ }),
96
+ "description?": type("string")
97
+ .configure({
98
+ description: "package description",
99
+ })
100
+ .narrow((desc: string, ctx) => {
101
+ return desc.length <= 300 || ctx.reject("description too long (max 300 characters)");
102
+ }),
103
+
104
+ // Commands configuration
105
+ "commands?": type({
106
+ "directory?": SafePath.configure({
107
+ description:
108
+ "directory containing command files following pattern: <cmd-name>/cmd.{ts,js,mjs}",
109
+ }),
110
+ "generateReport?": "boolean",
111
+ }).configure({
112
+ description: "command-related configuration",
113
+ }),
114
+
115
+ // Build configuration - TypeScript REQUIRED
116
+ "build?": type({
117
+ "entry?": type("string|string[]")
118
+ .configure({
119
+ description: "entry file(s) for bundling",
120
+ })
121
+ .narrow((entry: string | string[], _ctx) => {
122
+ const entries = Array.isArray(entry) ? entry : [entry];
123
+ if (entries.length === 0) {
124
+ return _ctx.reject("at least one entry file must be specified");
125
+ }
126
+ // Check for common file extensions
127
+ const hasValidExtension = entries.every((e) => /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(e));
128
+ if (!hasValidExtension) {
129
+ // Note: ctx.warn is not available in current arktype version
130
+ console.warn(
131
+ "entry files should typically have .ts, .tsx, .js, .jsx, .mjs, or .cjs extensions"
132
+ );
133
+ }
134
+ return true;
135
+ }),
136
+ "outdir?": SafePath.configure({
137
+ description: "output directory for build artifacts",
138
+ }),
139
+ "targets?": BuildTargets,
140
+ "compress?": "boolean",
141
+ "minify?": "boolean",
142
+ "external?": type("string[]")
143
+ .configure({
144
+ description: "external dependencies to exclude from bundle",
145
+ })
146
+ .narrow((externals: string[], _ctx) => {
147
+ // Warn about potentially problematic externals
148
+ const problematic = externals.filter(
149
+ (ext) => ext.startsWith("@types/") || ext.includes("*")
150
+ );
151
+ if (problematic.length > 0) {
152
+ // Note: ctx.warn is not available in current arktype version
153
+ console.warn(`potentially problematic externals detected: ${problematic.join(", ")}`);
154
+ }
155
+ return true;
156
+ }),
157
+ "sourcemap?": "boolean",
158
+ }).configure({
159
+ description: "build configuration for bundling and compilation",
160
+ }),
161
+
162
+ // Development configuration
163
+ "dev?": type({
164
+ "watch?": "boolean",
165
+ "inspect?": "boolean",
166
+ "port?": PortNumber,
167
+ }).configure({
168
+ description: "development server configuration",
169
+ }),
170
+
171
+ // Test configuration
172
+ "test?": type({
173
+ "pattern?": type("string|string[]")
174
+ .configure({
175
+ description: "glob patterns for test files",
176
+ })
177
+ .narrow((patterns: string | string[], _ctx) => {
178
+ const patternList = Array.isArray(patterns) ? patterns : [patterns];
179
+ // Basic validation for glob patterns
180
+ const invalidPatterns = patternList.filter((p) => p.includes("../") || p.startsWith("/"));
181
+ if (invalidPatterns.length > 0) {
182
+ // Note: ctx.warn is not available in current arktype version
183
+ console.warn(`glob patterns should be relative: ${invalidPatterns.join(", ")}`);
184
+ }
185
+ return true;
186
+ }),
187
+ "coverage?": "boolean",
188
+ "watch?": "boolean",
189
+ }).configure({
190
+ description: "test configuration",
191
+ }),
192
+
193
+ // Workspace configuration
194
+ "workspace?": type({
195
+ "packages?": type("string[]")
196
+ .configure({
197
+ description: "array of package paths in the workspace",
198
+ })
199
+ .narrow((packages: string[], _ctx) => {
200
+ if (packages.length === 0) {
201
+ // Note: ctx.warn is not available in current arktype version
202
+ console.warn("workspace.packages is empty - no packages will be included");
203
+ }
204
+ // Check for relative paths
205
+ const absolutePaths = packages.filter((pkg) => pkg.startsWith("/"));
206
+ if (absolutePaths.length > 0) {
207
+ // Note: ctx.warn is not available in current arktype version
208
+ console.warn("workspace packages should use relative paths, not absolute paths");
209
+ }
210
+ return true;
211
+ }),
212
+ "shared?": "unknown",
213
+ "versionStrategy?": VersionStrategy.configure({
214
+ description: "how versions are managed across workspace packages",
215
+ }),
216
+ }).configure({
217
+ description: "monorepo workspace configuration",
218
+ }),
219
+
220
+ // Release configuration
221
+ "release?": type({
222
+ "npm?": "boolean",
223
+ "github?": "boolean",
224
+ "tagFormat?": type("string")
225
+ .configure({
226
+ description: "format for git tags (e.g., 'v${version}')",
227
+ })
228
+ .narrow((format: string, ctx) => {
229
+ if (!format.includes("${version}")) {
230
+ return ctx.reject("tagFormat must include '${version}' placeholder");
231
+ }
232
+ return true;
233
+ }),
234
+ "conventionalCommits?": "boolean",
235
+ }).configure({
236
+ description: "release and publishing configuration",
237
+ }),
238
+
239
+ // Plugins configuration
240
+ "plugins?": type("unknown[]").configure({
241
+ description: "array of plugin configurations",
242
+ }),
243
+ }).configure({
244
+ description: "Rempts CLI configuration schema with comprehensive validation",
245
+ });
246
+
247
+ /**
248
+ * Inferred TypeScript type from the schema
249
+ * This ensures runtime validation matches compile-time types
250
+ */
251
+ export type RemptsConfig = typeof remptsConfigSchema.infer;
252
+
253
+ /**
254
+ * Strict schema for CLI creation that requires name and version
255
+ * Codegen and TypeScript are automatically enabled
256
+ */
257
+ export const remptsConfigStrictSchema = remptsConfigSchema.and({
258
+ name: "string",
259
+ version: "string.semver",
260
+ });
261
+
262
+ export type RemptsConfigStrict = typeof remptsConfigStrictSchema.infer;
263
+
264
+ /**
265
+ * Helper function to define configuration with type safety
266
+ * Codegen and TypeScript are automatically configured
267
+ */
268
+ export function defineConfig(config: RemptsConfig): RemptsConfig {
269
+ return config;
270
+ }
@@ -0,0 +1,346 @@
1
+ import { join, relative } from "node:path";
2
+ import type { Command } from "./types";
3
+
4
+ /**
5
+ * File-based command loader that automatically discovers and loads commands
6
+ * from a directory structure following the pattern: <cmds-dir>/<cmd-name>/cmd.{ts,js,mjs}
7
+ */
8
+
9
+ // Pre-compiled regex patterns for performance
10
+ const CMD_FILE_PATTERN = /\/cmd\.[^.]+$/;
11
+ const PATH_SEPARATOR_PATTERN = /\//g;
12
+
13
+ /**
14
+ * Create a file command loader
15
+ */
16
+ export function createFileCommandLoader() {
17
+ return {
18
+ /**
19
+ * Load commands from a directory structure
20
+ * @param cmdsDir Directory containing command files following pattern: <cmd-name>/cmd.{ts,js,mjs}
21
+ * @returns Promise resolving to loaded command tree
22
+ */
23
+ async loadFromDirectory(cmdsDir: string): Promise<CommandFileTree> {
24
+ const commandFiles = await scanCommandFiles(cmdsDir);
25
+ const conflicts = detectConflicts(commandFiles, cmdsDir);
26
+
27
+ if (conflicts.length > 0) {
28
+ const conflictMessages = conflicts.map(
29
+ (conflict) =>
30
+ `Command "${conflict.commandName}" conflicts between:\n` +
31
+ ` - ${conflict.files[0]}\n` +
32
+ ` - ${conflict.files[1]}`
33
+ );
34
+ throw new Error(`Command conflicts detected:\n${conflictMessages.join("\n\n")}`);
35
+ }
36
+
37
+ return buildCommandTree(commandFiles, cmdsDir);
38
+ },
39
+
40
+ /**
41
+ * Load and register commands from file tree
42
+ * Returns commands with their names inferred from file paths
43
+ */
44
+ async loadCommandsFromTree(tree: CommandFileTree): Promise<CommandWithName[]> {
45
+ return loadCommandsFromTree(tree);
46
+ },
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Scan for command files in directory
52
+ * Only finds files matching the pattern: <cmd-name>/cmd.{ts,js,mjs}
53
+ */
54
+ async function scanCommandFiles(cmdsDir: string): Promise<string[]> {
55
+ try {
56
+ // Only look for cmd.{ts,js,mjs} files in subdirectories
57
+ const glob = new Bun.Glob("**/cmd.{ts,js,mjs}");
58
+ const files = await Array.fromAsync(glob.scan({ cwd: cmdsDir }));
59
+
60
+ const commandFiles: string[] = [];
61
+
62
+ // Process files in parallel for better performance
63
+ for (const file of files) {
64
+ const fullPath = join(cmdsDir, file);
65
+ // All cmd.{ts,js,mjs} files in subdirectories are considered valid command files
66
+ commandFiles.push(fullPath);
67
+ }
68
+
69
+ return commandFiles;
70
+ } catch {
71
+ console.warn(`Warning: Could not scan commands directory: ${cmdsDir}`);
72
+ return [];
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Detect conflicts between command files
78
+ */
79
+ function detectConflicts(commandFiles: string[], cmdsDir: string): CommandConflict[] {
80
+ const conflicts: CommandConflict[] = [];
81
+ const commandMap = new Map<string, string[]>(); // commandName -> [filePaths]
82
+ const directoryMap = new Map<string, string[]>(); // directory -> [filePaths]
83
+
84
+ for (const filePath of commandFiles) {
85
+ const relativePath = relative(cmdsDir, filePath);
86
+ const commandName = getCommandName(relativePath);
87
+
88
+ // Track files by command name
89
+ if (!commandMap.has(commandName)) {
90
+ commandMap.set(commandName, []);
91
+ }
92
+ commandMap.get(commandName)?.push(filePath);
93
+
94
+ // Track files by directory (for variant conflicts)
95
+ const directory = relativePath.replace(CMD_FILE_PATTERN, "");
96
+ if (!directoryMap.has(directory)) {
97
+ directoryMap.set(directory, []);
98
+ }
99
+ directoryMap.get(directory)?.push(filePath);
100
+ }
101
+
102
+ // Check for multiple file variants in same directory first (more specific error)
103
+ const directoriesWithVariants = new Set<string>();
104
+ for (const [directory, files] of directoryMap) {
105
+ if (files.length > 1) {
106
+ conflicts.push({
107
+ commandName: `${directory} has multiple file variants`,
108
+ files,
109
+ });
110
+ directoriesWithVariants.add(directory);
111
+ }
112
+ }
113
+
114
+ // Check for multiple files mapping to same command name (but skip if already reported as variant conflict)
115
+ for (const [commandName, files] of commandMap) {
116
+ if (files.length > 1 && !directoriesWithVariants.has(commandName)) {
117
+ conflicts.push({ commandName, files });
118
+ }
119
+ }
120
+
121
+ return conflicts;
122
+ }
123
+
124
+ /**
125
+ * Get command name from file path
126
+ * For the strict structure: <commands-dir>/<command>/cmd.{ts,js,mjs}
127
+ * - greet/cmd.ts -> "greet"
128
+ * - git/status/cmd.ts -> "git status"
129
+ */
130
+ function getCommandName(filePath: string): string {
131
+ // Remove the "cmd" part and extension: "greet/cmd.ts" -> "greet/"
132
+ const pathWithoutCmd = filePath.replace(CMD_FILE_PATTERN, "");
133
+
134
+ // Remove trailing slash if present: "greet/" -> "greet"
135
+ const trimmed = pathWithoutCmd.replace(/\/$/, "");
136
+
137
+ // Convert path separators to spaces for command hierarchy
138
+ // Handle multiple consecutive slashes and normalize
139
+ return trimmed.replace(PATH_SEPARATOR_PATTERN, " ").replace(/\s+/g, " ").trim();
140
+ }
141
+
142
+ /**
143
+ * Build command tree from file structure
144
+ */
145
+ async function buildCommandTree(commandFiles: string[], cmdsDir: string): Promise<CommandFileTree> {
146
+ const tree: CommandFileTree = {};
147
+
148
+ for (const filePath of commandFiles) {
149
+ const relativePath = relative(cmdsDir, filePath);
150
+ const commandName = getCommandName(relativePath);
151
+
152
+ // For strict structure, command name is just the directory name(s)
153
+ // e.g., "greet/cmd.ts" -> "greet", "build/binary/cmd.ts" -> "build binary"
154
+ const commandNameParts = commandName.split(" ");
155
+ let current = tree;
156
+
157
+ // Build nested structure for multi-level commands
158
+ // If a parent command file exists (e.g., build/cmd.ts), we need to preserve it
159
+ for (let i = 0; i < commandNameParts.length - 1; i++) {
160
+ const part = commandNameParts[i]!;
161
+ if (!current[part]) {
162
+ current[part] = {};
163
+ } else if ("filePath" in current[part]) {
164
+ // Parent command file exists (e.g., build/cmd.ts)
165
+ // Convert it to a tree structure to hold subcommands
166
+ const existingCommand = current[part] as CommandFileInfo;
167
+ current[part] = {
168
+ // Store the parent command file under a special key or as the base
169
+ // We'll handle this in loadCommandsFromTree
170
+ } as CommandFileTree;
171
+ // Re-add the parent command file to the tree
172
+ const treeNode = current[part] as CommandFileTree;
173
+ (treeNode as Record<string, unknown>).__parent__ = existingCommand;
174
+ }
175
+ current = current[part] as CommandFileTree;
176
+ }
177
+
178
+ const finalPart = commandNameParts.at(-1)!;
179
+
180
+ // If this is a single-part command (e.g., "build") and there's already a tree here,
181
+ // it means we have subcommands - store the parent command file separately
182
+ if (
183
+ commandNameParts.length === 1 &&
184
+ finalPart in current &&
185
+ "filePath" in current[finalPart]!
186
+ ) {
187
+ // This shouldn't happen - single-part commands shouldn't conflict
188
+ // But handle it gracefully
189
+ const existing = current[finalPart] as CommandFileInfo;
190
+ if (existing.filePath !== relativePath) {
191
+ throw new Error(
192
+ `Command conflict: "${finalPart}" is defined in both "${existing.filePath}" and "${relativePath}"`
193
+ );
194
+ }
195
+ }
196
+
197
+ (current as any)[finalPart] = {
198
+ filePath: relativePath,
199
+ importPath: getImportPath(filePath),
200
+ commandName,
201
+ };
202
+ }
203
+
204
+ return tree;
205
+ }
206
+
207
+ /**
208
+ * Get import path for a command file
209
+ */
210
+ function getImportPath(filePath: string): string {
211
+ // For dynamic imports, return the file:// URL for the absolute path
212
+ // This ensures proper resolution regardless of the importing module's location
213
+ return `file://${filePath}`;
214
+ }
215
+
216
+ /**
217
+ * Command with its inferred name from file path
218
+ */
219
+ export interface CommandWithName {
220
+ name: string;
221
+ command: Command<any, any>;
222
+ }
223
+
224
+ /**
225
+ * Load and register commands from file tree
226
+ * Returns commands with their names inferred from file paths
227
+ */
228
+ async function loadCommandsFromTree(tree: CommandFileTree): Promise<CommandWithName[]> {
229
+ async function loadFromTree(
230
+ obj: CommandFileTree,
231
+ path: string[] = []
232
+ ): Promise<CommandWithName[]> {
233
+ const loadedCommands: CommandWithName[] = [];
234
+
235
+ for (const [key, value] of Object.entries(obj)) {
236
+ if (key === "__parent__") {
237
+ // This is a parent command file stored in a tree (e.g., build/cmd.ts when build/binary/cmd.ts exists)
238
+ const commandInfo = value as CommandFileInfo;
239
+ try {
240
+ const module = await import(commandInfo.importPath);
241
+ const command = module.default || module;
242
+
243
+ // Load the parent command with its full name
244
+ loadedCommands.push({
245
+ name: commandInfo.commandName,
246
+ command,
247
+ });
248
+ } catch (error) {
249
+ console.warn(`Failed to load command from ${commandInfo.importPath}:`, error);
250
+ }
251
+ continue;
252
+ }
253
+
254
+ if (typeof value === "object" && "filePath" in value) {
255
+ // This is a command file (leaf node)
256
+ const commandInfo = value as CommandFileInfo;
257
+ try {
258
+ const module = await import(commandInfo.importPath);
259
+ const command = module.default || module;
260
+
261
+ // Name is always inferred from file path
262
+ // e.g., "build/binary/cmd.ts" -> "build binary"
263
+ const inferredName = commandInfo.commandName;
264
+
265
+ loadedCommands.push({
266
+ name: inferredName,
267
+ command,
268
+ });
269
+ } catch (error) {
270
+ console.warn(`Failed to load command from ${commandInfo.importPath}:`, error);
271
+ }
272
+ } else {
273
+ // This is a nested tree - may contain subcommands and/or a parent command
274
+ const subCommands = await loadFromTree(value as CommandFileTree, [...path, key]);
275
+ if (subCommands.length > 0) {
276
+ // Check if there's a parent command file (stored as __parent__)
277
+ const parentCommandInfo = (value as Record<string, unknown>).__parent__ as
278
+ | CommandFileInfo
279
+ | undefined;
280
+
281
+ if (parentCommandInfo) {
282
+ // Parent command file exists (e.g., build/cmd.ts)
283
+ // Load it - subcommands are already registered separately from files
284
+ try {
285
+ const module = await import(parentCommandInfo.importPath);
286
+ const parentCommand = module.default || module;
287
+
288
+ // Register the parent command - subcommands are discovered dynamically from commands map
289
+ loadedCommands.push({
290
+ name: parentCommandInfo.commandName,
291
+ command: parentCommand,
292
+ });
293
+ } catch (error) {
294
+ console.warn(
295
+ `Failed to load parent command from ${parentCommandInfo.importPath}:`,
296
+ error
297
+ );
298
+ }
299
+ } else {
300
+ // No parent command file - create a synthetic parent command
301
+ // Subcommands are discovered dynamically from the commands map
302
+ // The synthetic parent has no handler/render, so run() will show help when subcommands exist
303
+ const parentCommand: Command<any, any> = {
304
+ description: `${key} commands`,
305
+ };
306
+ loadedCommands.push({
307
+ name: key,
308
+ command: parentCommand,
309
+ });
310
+ }
311
+ }
312
+ }
313
+ }
314
+
315
+ return loadedCommands;
316
+ }
317
+
318
+ return await loadFromTree(tree);
319
+ }
320
+
321
+ /**
322
+ * Types for file-based command loading
323
+ */
324
+ export interface CommandFileInfo {
325
+ filePath: string;
326
+ importPath: string;
327
+ commandName: string;
328
+ }
329
+
330
+ export interface CommandFileTree {
331
+ [key: string]: CommandFileTree | CommandFileInfo | undefined;
332
+ }
333
+
334
+ export interface CommandConflict {
335
+ commandName: string;
336
+ files: string[];
337
+ }
338
+
339
+ /**
340
+ * Utility function to load commands from directory
341
+ */
342
+ export async function loadCommandsFromDirectory(cmdsDir: string): Promise<CommandWithName[]> {
343
+ const loader = createFileCommandLoader();
344
+ const tree = await loader.loadFromDirectory(cmdsDir);
345
+ return loader.loadCommandsFromTree(tree);
346
+ }