@rune-cli/rune 0.0.9 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -1,101 +1,247 @@
1
1
  #!/usr/bin/env node
2
- import "./dist-DuisScgY.mjs";
3
- import { n as isHelpFlag, r as isVersionFlag, t as runManifestCommand } from "./run-manifest-command-BphalAwU.mjs";
2
+ import "./dist-Bpf2xVvb.mjs";
3
+ import { n as isHelpFlag, r as isVersionFlag, t as runManifestCommand } from "./run-manifest-command-DepwxrFI.mjs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { build } from "esbuild";
6
6
  import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
7
7
  import path from "node:path";
8
8
  import ts from "typescript";
9
9
  //#region package.json
10
- var version = "0.0.9";
10
+ var version = "0.0.11";
11
11
  //#endregion
12
- //#region src/manifest/generate-manifest.ts
13
- const COMMAND_ENTRY_FILE = "index.ts";
14
- function comparePathSegments(left, right) {
15
- const length = Math.min(left.length, right.length);
16
- for (let index = 0; index < length; index += 1) {
17
- const comparison = left[index].localeCompare(right[index]);
18
- if (comparison !== 0) return comparison;
19
- }
20
- return left.length - right.length;
21
- }
12
+ //#region src/manifest/generate/extract-description.ts
22
13
  function getPropertyNameText(name) {
23
14
  if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text;
24
15
  }
25
- function getStaticDescriptionValue(expression) {
16
+ function getStaticStringValue(expression) {
26
17
  if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) return expression.text;
27
18
  }
19
+ function getStaticStringArrayValue(expression) {
20
+ if (!ts.isArrayLiteralExpression(expression)) return;
21
+ const values = [];
22
+ for (const element of expression.elements) {
23
+ const value = getStaticStringValue(element);
24
+ if (value === void 0) return;
25
+ values.push(value);
26
+ }
27
+ return values;
28
+ }
29
+ function resolveExpression(expression, sourceFile) {
30
+ if (ts.isIdentifier(expression)) return findVariableInitializer(sourceFile, expression.text) ?? expression;
31
+ return expression;
32
+ }
28
33
  function isDefineCommandExpression(expression) {
29
- if (ts.isIdentifier(expression)) return expression.text === "defineCommand";
30
- if (ts.isPropertyAccessExpression(expression)) return expression.name.text === "defineCommand";
31
- return false;
34
+ return isNamedCallExpression(expression, "defineCommand");
32
35
  }
33
- function extractDescriptionFromCommandDefinition(expression, knownDescriptions) {
34
- if (ts.isIdentifier(expression)) return knownDescriptions.get(expression.text);
35
- if (!ts.isCallExpression(expression) || !isDefineCommandExpression(expression.expression)) return;
36
+ function isDefineGroupExpression(expression) {
37
+ return isNamedCallExpression(expression, "defineGroup");
38
+ }
39
+ function isDefineCallExpression(expression) {
40
+ return ts.isCallExpression(expression) && (isDefineCommandExpression(expression.expression) || isDefineGroupExpression(expression.expression));
41
+ }
42
+ function extractMetadataFromCommandDefinition(expression, sourceFile, knownMetadata) {
43
+ if (ts.isIdentifier(expression)) return knownMetadata.get(expression.text);
44
+ if (!ts.isCallExpression(expression) || !isDefineCallExpression(expression)) return;
36
45
  const [definition] = expression.arguments;
37
46
  if (!definition || !ts.isObjectLiteralExpression(definition)) return;
47
+ let description;
48
+ let aliases = [];
38
49
  for (const property of definition.properties) {
50
+ if (ts.isShorthandPropertyAssignment(property)) {
51
+ const propertyName = property.name.text;
52
+ const resolved = resolveExpression(property.name, sourceFile);
53
+ if (propertyName === "description") description = getStaticStringValue(resolved);
54
+ else if (propertyName === "aliases") {
55
+ const extracted = getStaticStringArrayValue(resolved);
56
+ if (extracted === void 0) throw new Error("Could not statically analyze aliases. Aliases must be an inline array of string literals (e.g. aliases: [\"d\"]).");
57
+ aliases = extracted;
58
+ }
59
+ continue;
60
+ }
39
61
  if (!ts.isPropertyAssignment(property)) continue;
40
- if (getPropertyNameText(property.name) !== "description") continue;
41
- return getStaticDescriptionValue(property.initializer);
62
+ const propertyName = getPropertyNameText(property.name);
63
+ const resolved = resolveExpression(property.initializer, sourceFile);
64
+ if (propertyName === "description") description = getStaticStringValue(resolved);
65
+ else if (propertyName === "aliases") {
66
+ const extracted = getStaticStringArrayValue(resolved);
67
+ if (extracted === void 0) throw new Error("Could not statically analyze aliases. Aliases must be an inline array of string literals (e.g. aliases: [\"d\"]).");
68
+ aliases = extracted;
69
+ }
42
70
  }
71
+ return {
72
+ description,
73
+ aliases
74
+ };
75
+ }
76
+ function findVariableInitializer(sourceFile, name) {
77
+ for (const statement of sourceFile.statements) {
78
+ if (!ts.isVariableStatement(statement)) continue;
79
+ for (const declaration of statement.declarationList.declarations) if (ts.isIdentifier(declaration.name) && declaration.name.text === name && declaration.initializer) return declaration.initializer;
80
+ }
81
+ }
82
+ function isNamedCallExpression(expression, name) {
83
+ if (ts.isIdentifier(expression)) return expression.text === name;
84
+ if (ts.isPropertyAccessExpression(expression)) return expression.name.text === name;
85
+ return false;
43
86
  }
44
- async function extractDescriptionFromSourceFile(sourceFilePath) {
87
+ async function extractMetadataFromSourceFile(sourceFilePath) {
45
88
  const sourceText = await readFile(sourceFilePath, "utf8");
46
89
  const sourceFile = ts.createSourceFile(sourceFilePath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
47
- const knownDescriptions = /* @__PURE__ */ new Map();
90
+ const knownMetadata = /* @__PURE__ */ new Map();
48
91
  for (const statement of sourceFile.statements) {
49
92
  if (ts.isVariableStatement(statement)) {
50
93
  for (const declaration of statement.declarationList.declarations) {
51
94
  if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue;
52
- knownDescriptions.set(declaration.name.text, extractDescriptionFromCommandDefinition(declaration.initializer, knownDescriptions));
95
+ knownMetadata.set(declaration.name.text, extractMetadataFromCommandDefinition(declaration.initializer, sourceFile, knownMetadata));
53
96
  }
54
97
  continue;
55
98
  }
56
- if (ts.isExportAssignment(statement)) return extractDescriptionFromCommandDefinition(statement.expression, knownDescriptions);
99
+ if (ts.isExportAssignment(statement)) return extractMetadataFromCommandDefinition(statement.expression, sourceFile, knownMetadata);
57
100
  }
58
101
  }
59
- async function walkCommandsDirectory(absoluteDirectoryPath, pathSegments, extractDescription) {
102
+ //#endregion
103
+ //#region src/manifest/generate/validate-group-meta.ts
104
+ async function validateGroupMetaFile(sourceFilePath) {
105
+ const sourceText = await readFile(sourceFilePath, "utf8");
106
+ const sourceFile = ts.createSourceFile(sourceFilePath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
107
+ for (const statement of sourceFile.statements) {
108
+ if (ts.isVariableStatement(statement) || ts.isImportDeclaration(statement)) continue;
109
+ if (ts.isExportAssignment(statement)) {
110
+ const expression = ts.isIdentifier(statement.expression) ? findVariableInitializer(sourceFile, statement.expression.text) : statement.expression;
111
+ if (expression && ts.isCallExpression(expression) && isDefineGroupExpression(expression.expression)) return;
112
+ throw new Error(`${sourceFilePath}: _group.ts must use "export default defineGroup(...)". Found a default export that is not a defineGroup() call.`);
113
+ }
114
+ }
115
+ throw new Error(`${sourceFilePath}: _group.ts must have a default export using defineGroup().`);
116
+ }
117
+ //#endregion
118
+ //#region src/manifest/generate/walk-commands-directory.ts
119
+ const COMMAND_ENTRY_FILE = "index.ts";
120
+ const GROUP_META_FILE = "_group.ts";
121
+ const BARE_COMMAND_EXTENSION = ".ts";
122
+ const DECLARATION_FILE_SUFFIXES = [
123
+ ".d.ts",
124
+ ".d.mts",
125
+ ".d.cts"
126
+ ];
127
+ function comparePathSegments(left, right) {
128
+ const length = Math.min(left.length, right.length);
129
+ for (let index = 0; index < length; index += 1) {
130
+ const comparison = left[index].localeCompare(right[index]);
131
+ if (comparison !== 0) return comparison;
132
+ }
133
+ return left.length - right.length;
134
+ }
135
+ function validateSiblingAliases(siblings) {
136
+ const seen = /* @__PURE__ */ new Map();
137
+ for (const sibling of siblings) {
138
+ const existing = seen.get(sibling.name);
139
+ if (existing !== void 0) throw new Error(`Command name conflict: "${sibling.name}" is already used by "${existing}".`);
140
+ seen.set(sibling.name, sibling.name);
141
+ for (const alias of sibling.aliases) {
142
+ if (alias === sibling.name) throw new Error(`Command alias "${alias}" for "${sibling.name}" is the same as its canonical name.`);
143
+ const conflicting = seen.get(alias);
144
+ if (conflicting !== void 0) throw new Error(`Command alias conflict: alias "${alias}" for "${sibling.name}" conflicts with "${conflicting}".`);
145
+ seen.set(alias, sibling.name);
146
+ }
147
+ }
148
+ }
149
+ async function walkCommandsDirectory(absoluteDirectoryPath, pathSegments, extractMetadata) {
60
150
  const entries = await readdir(absoluteDirectoryPath, { withFileTypes: true });
61
151
  const childDirectoryNames = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort((left, right) => left.localeCompare(right));
62
152
  const childResults = await Promise.all(childDirectoryNames.map(async (directoryName) => {
63
153
  return {
64
154
  directoryName,
65
- result: await walkCommandsDirectory(path.join(absoluteDirectoryPath, directoryName), [...pathSegments, directoryName], extractDescription)
155
+ result: await walkCommandsDirectory(path.join(absoluteDirectoryPath, directoryName), [...pathSegments, directoryName], extractMetadata)
66
156
  };
67
157
  }));
68
- const childNodes = childResults.flatMap(({ result }) => result.nodes);
69
- const childNames = childResults.filter(({ result }) => result.hasNode).map(({ directoryName }) => directoryName);
158
+ const bareCommandFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith(BARE_COMMAND_EXTENSION) && entry.name !== COMMAND_ENTRY_FILE && entry.name !== GROUP_META_FILE && !DECLARATION_FILE_SUFFIXES.some((suffix) => entry.name.endsWith(suffix))).map((entry) => entry.name).sort((left, right) => left.localeCompare(right));
159
+ const childDirectoriesWithNodes = new Set(childResults.filter(({ result }) => result.hasNode).map(({ directoryName }) => directoryName));
160
+ const bareCommandNodes = await Promise.all(bareCommandFiles.map(async (fileName) => {
161
+ const commandName = fileName.slice(0, -3);
162
+ if (childDirectoriesWithNodes.has(commandName)) throw new Error(`Conflicting command definitions: both "${commandName}${BARE_COMMAND_EXTENSION}" and "${commandName}/" exist. A bare command file cannot coexist with a command directory.`);
163
+ const sourceFilePath = path.join(absoluteDirectoryPath, fileName);
164
+ const metadata = await extractMetadata(sourceFilePath);
165
+ return {
166
+ pathSegments: [...pathSegments, commandName],
167
+ kind: "command",
168
+ sourceFilePath,
169
+ childNames: [],
170
+ aliases: metadata?.aliases ?? [],
171
+ description: metadata?.description
172
+ };
173
+ }));
174
+ const descendantNodes = childResults.flatMap(({ result }) => result.nodes);
175
+ const childNames = [...childResults.filter(({ result }) => result.hasNode).map(({ directoryName }) => directoryName), ...bareCommandFiles.map((fileName) => fileName.slice(0, -3))].sort((left, right) => left.localeCompare(right));
70
176
  const hasCommandEntry = entries.some((entry) => entry.isFile() && entry.name === COMMAND_ENTRY_FILE);
71
- if (!hasCommandEntry && childNames.length === 0) return {
72
- nodes: childNodes,
177
+ const hasGroupMeta = entries.some((entry) => entry.isFile() && entry.name === GROUP_META_FILE);
178
+ if (hasGroupMeta && hasCommandEntry) throw new Error(`Conflicting definitions: both "${GROUP_META_FILE}" and "${COMMAND_ENTRY_FILE}" exist in the same directory. A directory is either a group (_group.ts) or an executable command (index.ts), not both.`);
179
+ if (hasGroupMeta && childNames.length === 0) throw new Error(`${path.join(absoluteDirectoryPath, GROUP_META_FILE)}: _group.ts exists but the directory has no subcommands.`);
180
+ if (!hasCommandEntry && !hasGroupMeta && childNames.length === 0) return {
181
+ nodes: [...descendantNodes, ...bareCommandNodes],
73
182
  hasNode: false
74
183
  };
75
184
  let node;
76
185
  if (hasCommandEntry) {
77
186
  const sourceFilePath = path.join(absoluteDirectoryPath, COMMAND_ENTRY_FILE);
187
+ const metadata = await extractMetadata(sourceFilePath);
188
+ const aliases = metadata?.aliases ?? [];
189
+ if (aliases.length > 0 && pathSegments.length === 0) throw new Error("Aliases on the root command are not supported. The root command has no parent to resolve aliases against.");
78
190
  node = {
79
191
  pathSegments,
80
192
  kind: "command",
81
193
  sourceFilePath,
82
194
  childNames,
83
- description: await extractDescription(sourceFilePath)
195
+ aliases,
196
+ description: metadata?.description
84
197
  };
85
- } else node = {
86
- pathSegments,
87
- kind: "group",
88
- childNames
89
- };
198
+ } else {
199
+ const groupMetaPath = path.join(absoluteDirectoryPath, GROUP_META_FILE);
200
+ if (hasGroupMeta) await validateGroupMetaFile(groupMetaPath);
201
+ const metadata = hasGroupMeta ? await extractMetadata(groupMetaPath) : void 0;
202
+ if (hasGroupMeta && !metadata?.description) throw new Error(`${groupMetaPath}: _group.ts must export a defineGroup() call with a non-empty "description" string.`);
203
+ const aliases = metadata?.aliases ?? [];
204
+ if (aliases.length > 0 && pathSegments.length === 0) throw new Error("Aliases on the root group are not supported. The root group has no parent to resolve aliases against.");
205
+ node = {
206
+ pathSegments,
207
+ kind: "group",
208
+ childNames,
209
+ aliases,
210
+ ...metadata?.description !== void 0 ? { description: metadata.description } : {}
211
+ };
212
+ }
213
+ const siblingEntries = [];
214
+ for (const childResult of childResults) {
215
+ if (!childResult.result.hasNode) continue;
216
+ const childNode = childResult.result.nodes.find((n) => n.pathSegments.length === pathSegments.length + 1 && n.pathSegments[pathSegments.length] === childResult.directoryName);
217
+ siblingEntries.push({
218
+ name: childResult.directoryName,
219
+ aliases: childNode?.aliases ?? []
220
+ });
221
+ }
222
+ for (const bareNode of bareCommandNodes) {
223
+ const name = bareNode.pathSegments[bareNode.pathSegments.length - 1];
224
+ siblingEntries.push({
225
+ name,
226
+ aliases: bareNode.aliases
227
+ });
228
+ }
229
+ validateSiblingAliases(siblingEntries);
90
230
  return {
91
- nodes: [node, ...childNodes],
231
+ nodes: [
232
+ node,
233
+ ...descendantNodes,
234
+ ...bareCommandNodes
235
+ ],
92
236
  hasNode: true
93
237
  };
94
238
  }
239
+ //#endregion
240
+ //#region src/manifest/generate/generate-manifest.ts
95
241
  async function generateCommandManifest(options) {
96
- const extractDescription = options.extractDescription ?? extractDescriptionFromSourceFile;
97
- const walkResult = await walkCommandsDirectory(options.commandsDirectory, [], extractDescription);
98
- if (walkResult.nodes.length === 0) throw new Error("No commands found in src/commands/. Create a command file like src/commands/hello/index.ts");
242
+ const extractMetadata = options.extractMetadata ?? extractMetadataFromSourceFile;
243
+ const walkResult = await walkCommandsDirectory(options.commandsDirectory, [], extractMetadata);
244
+ if (walkResult.nodes.length === 0) throw new Error("No commands found in src/commands/. Create a command file like src/commands/hello.ts or src/commands/hello/index.ts");
99
245
  return { nodes: [...walkResult.nodes].sort((left, right) => comparePathSegments(left.pathSegments, right.pathSegments)) };
100
246
  }
101
247
  function serializeCommandManifest(manifest) {
@@ -119,19 +265,20 @@ function resolveCommandsDirectory(projectRoot) {
119
265
  function resolveDistDirectory(projectRoot) {
120
266
  return path.join(projectRoot, DIST_DIRECTORY_NAME);
121
267
  }
268
+ function resolveCliNameFromPackageJson(packageJson) {
269
+ if (packageJson.bin && typeof packageJson.bin === "object") {
270
+ const binNames = Object.keys(packageJson.bin).sort((left, right) => left.localeCompare(right));
271
+ if (binNames.length > 0) return binNames[0];
272
+ }
273
+ if (packageJson.name && packageJson.name.length > 0) return packageJson.name.split("/").at(-1) ?? packageJson.name;
274
+ }
122
275
  async function readProjectCliInfo(projectRoot) {
123
276
  const packageJsonPath = path.join(projectRoot, "package.json");
124
277
  try {
125
278
  const packageJsonContents = await readFile(packageJsonPath, "utf8");
126
279
  const packageJson = JSON.parse(packageJsonContents);
127
- let name;
128
- if (packageJson.bin && typeof packageJson.bin === "object") {
129
- const binNames = Object.keys(packageJson.bin).sort((left, right) => left.localeCompare(right));
130
- if (binNames.length > 0) name = binNames[0];
131
- }
132
- if (!name && packageJson.name && packageJson.name.length > 0) name = packageJson.name.split("/").at(-1) ?? packageJson.name;
133
280
  return {
134
- name: name ?? path.basename(projectRoot),
281
+ name: resolveCliNameFromPackageJson(packageJson) ?? path.basename(projectRoot),
135
282
  version: packageJson.version
136
283
  };
137
284
  } catch (error) {
@@ -478,6 +625,19 @@ function tryParseProjectOption(argv, index) {
478
625
  function getRuneVersion() {
479
626
  return version;
480
627
  }
628
+ function renderRuneCliHelp() {
629
+ return `\
630
+ Usage: rune <command>
631
+
632
+ Commands:
633
+ build Build a Rune project into a distributable CLI
634
+ dev Run a Rune project in development mode
635
+
636
+ Options:
637
+ -h, --help Show this help message
638
+ -V, --version Show the version number
639
+ `;
640
+ }
481
641
  function parseDevArgs(argv) {
482
642
  const commandArgs = [];
483
643
  let projectPath;
@@ -546,18 +706,22 @@ function parseBuildArgs(argv) {
546
706
  projectPath
547
707
  };
548
708
  }
549
- function renderRuneCliHelp() {
550
- return `\
551
- Usage: rune <command>
552
-
553
- Commands:
554
- build Build a Rune project into a distributable CLI
555
- dev Run a Rune project in development mode
556
-
557
- Options:
558
- -h, --help Show this help message
559
- -V, --version Show the version number
560
- `;
709
+ async function runDevSubcommand(options, restArgs) {
710
+ const parsedDevArgs = parseDevArgs(restArgs);
711
+ if (!parsedDevArgs.ok) return writeEarlyExit(parsedDevArgs);
712
+ return runDevCommand({
713
+ rawArgs: parsedDevArgs.commandArgs,
714
+ projectPath: parsedDevArgs.projectPath,
715
+ cwd: options.cwd
716
+ });
717
+ }
718
+ async function runBuildSubcommand(options, restArgs) {
719
+ const parsedBuildArgs = parseBuildArgs(restArgs);
720
+ if (!parsedBuildArgs.ok) return writeEarlyExit(parsedBuildArgs);
721
+ return runBuildCommand({
722
+ projectPath: parsedBuildArgs.projectPath,
723
+ cwd: options.cwd
724
+ });
561
725
  }
562
726
  async function runRuneCli(options) {
563
727
  const [subcommand, ...restArgs] = options.argv;
@@ -569,23 +733,8 @@ async function runRuneCli(options) {
569
733
  await writeStdout(`rune v${getRuneVersion()}\n`);
570
734
  return 0;
571
735
  }
572
- if (subcommand === "dev") {
573
- const parsedDevArgs = parseDevArgs(restArgs);
574
- if (!parsedDevArgs.ok) return writeEarlyExit(parsedDevArgs);
575
- return runDevCommand({
576
- rawArgs: parsedDevArgs.commandArgs,
577
- projectPath: parsedDevArgs.projectPath,
578
- cwd: options.cwd
579
- });
580
- }
581
- if (subcommand === "build") {
582
- const parsedBuildArgs = parseBuildArgs(restArgs);
583
- if (!parsedBuildArgs.ok) return writeEarlyExit(parsedBuildArgs);
584
- return runBuildCommand({
585
- projectPath: parsedBuildArgs.projectPath,
586
- cwd: options.cwd
587
- });
588
- }
736
+ if (subcommand === "dev") return runDevSubcommand(options, restArgs);
737
+ if (subcommand === "build") return runBuildSubcommand(options, restArgs);
589
738
  await writeStderrLine(`Unknown command: ${subcommand}. Available commands: build, dev`);
590
739
  return 1;
591
740
  }
@@ -1,11 +1,12 @@
1
- import { format, parseArgs } from "node:util";
1
+ import { parseArgs } from "node:util";
2
2
  //#region ../core/dist/index.mjs
3
3
  function isSchemaField(field) {
4
4
  return "schema" in field && field.schema !== void 0;
5
5
  }
6
6
  const DEFINED_COMMAND_BRAND = Symbol.for("@rune-cli/defined-command");
7
7
  const OPTION_NAME_RE = /^[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*$/;
8
- const ALIAS_RE = /^[a-zA-Z]$/;
8
+ const OPTION_SHORT_RE = /^[a-zA-Z]$/;
9
+ const COMMAND_ALIAS_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
9
10
  function validateFieldShape(fields, kind) {
10
11
  for (const field of fields) {
11
12
  const raw = field;
@@ -23,13 +24,21 @@ function validateUniqueFieldNames(fields, kind) {
23
24
  function validateOptionNames(options) {
24
25
  for (const field of options) if (!OPTION_NAME_RE.test(field.name)) throw new Error(`Invalid option name "${field.name}". Option names must start with a letter and contain only letters, numbers, and internal hyphens.`);
25
26
  }
26
- function validateOptionAliases(options) {
27
+ function validateOptionShortNames(options) {
27
28
  const seen = /* @__PURE__ */ new Set();
28
29
  for (const field of options) {
29
- if (field.alias === void 0) continue;
30
- if (!ALIAS_RE.test(field.alias)) throw new Error(`Invalid alias "${field.alias}" for option "${field.name}". Alias must be a single letter.`);
31
- if (seen.has(field.alias)) throw new Error(`Duplicate alias "${field.alias}" for option "${field.name}".`);
32
- seen.add(field.alias);
30
+ if (field.short === void 0) continue;
31
+ if (!OPTION_SHORT_RE.test(field.short)) throw new Error(`Invalid short name "${field.short}" for option "${field.name}". Short name must be a single letter.`);
32
+ if (seen.has(field.short)) throw new Error(`Duplicate short name "${field.short}" for option "${field.name}".`);
33
+ seen.add(field.short);
34
+ }
35
+ }
36
+ function validateCommandAliases(aliases) {
37
+ const seen = /* @__PURE__ */ new Set();
38
+ for (const alias of aliases) {
39
+ if (!COMMAND_ALIAS_RE.test(alias)) throw new Error(`Invalid command alias "${alias}". Aliases must be lowercase kebab-case (letters, digits, and internal hyphens).`);
40
+ if (seen.has(alias)) throw new Error(`Duplicate command alias "${alias}".`);
41
+ seen.add(alias);
33
42
  }
34
43
  }
35
44
  function isOptionalArg(field) {
@@ -59,7 +68,7 @@ function validateArgOrdering(args) {
59
68
  * { name: "name", type: "string", required: true },
60
69
  * ],
61
70
  * options: [
62
- * { name: "loud", type: "boolean", alias: "l" },
71
+ * { name: "loud", type: "boolean", short: "l" },
63
72
  * ],
64
73
  * run(ctx) {
65
74
  * const greeting = `Hello, ${ctx.args.name}!`;
@@ -98,6 +107,7 @@ function validateArgOrdering(args) {
98
107
  */
99
108
  function defineCommand(input) {
100
109
  if (typeof input.run !== "function") throw new Error("defineCommand() requires a \"run\" function.");
110
+ if (input.aliases) validateCommandAliases(input.aliases);
101
111
  if (input.args) {
102
112
  validateFieldShape(input.args, "argument");
103
113
  validateUniqueFieldNames(input.args, "argument");
@@ -107,10 +117,11 @@ function defineCommand(input) {
107
117
  validateFieldShape(input.options, "option");
108
118
  validateUniqueFieldNames(input.options, "option");
109
119
  validateOptionNames(input.options);
110
- validateOptionAliases(input.options);
120
+ validateOptionShortNames(input.options);
111
121
  }
112
122
  const command = {
113
123
  description: input.description,
124
+ aliases: input.aliases ?? [],
114
125
  args: input.args ?? [],
115
126
  options: input.options ?? [],
116
127
  run: input.run
@@ -124,17 +135,49 @@ function defineCommand(input) {
124
135
  function isDefinedCommand(value) {
125
136
  return typeof value === "object" && value !== null && value[DEFINED_COMMAND_BRAND] === true;
126
137
  }
138
+ const DEFINED_GROUP_BRAND = Symbol.for("@rune-cli/defined-group");
139
+ /**
140
+ * Defines metadata for a command group (a directory that only groups
141
+ * subcommands without being executable itself).
142
+ *
143
+ * Place the default export of this function in a `_group.ts` file inside a
144
+ * command directory.
145
+ *
146
+ * @example
147
+ * ```ts
148
+ * // src/commands/project/_group.ts
149
+ * export default defineGroup({
150
+ * description: "Manage projects",
151
+ * });
152
+ * ```
153
+ */
154
+ function defineGroup(input) {
155
+ if (typeof input.description !== "string" || input.description.length === 0) throw new Error("defineGroup() requires a non-empty \"description\" string.");
156
+ if (input.aliases) validateCommandAliases(input.aliases);
157
+ const group = {
158
+ description: input.description,
159
+ aliases: input.aliases ?? []
160
+ };
161
+ Object.defineProperty(group, DEFINED_GROUP_BRAND, {
162
+ value: true,
163
+ enumerable: false
164
+ });
165
+ return group;
166
+ }
127
167
  function formatExecutionError(error) {
128
168
  if (error instanceof Error) return error.message === "" ? "" : error.message || error.name || "Unknown error";
129
169
  if (typeof error === "string") return error;
130
170
  return "Unknown error";
131
171
  }
172
+ function createExecutionOptions(command, input) {
173
+ const options = { ...input.options };
174
+ for (const field of command.options) if (options[field.name] === void 0 && !isSchemaField(field) && field.type === "boolean") options[field.name] = false;
175
+ return options;
176
+ }
132
177
  async function executeCommand(command, input = {}) {
133
178
  try {
134
- const options = { ...input.options };
135
- for (const field of command.options) if (options[field.name] === void 0 && !isSchemaField(field) && field.type === "boolean") options[field.name] = false;
136
179
  await command.run({
137
- options,
180
+ options: createExecutionOptions(command, input),
138
181
  args: input.args ?? {},
139
182
  cwd: input.cwd ?? process.cwd(),
140
183
  rawArgs: input.rawArgs ?? []
@@ -148,62 +191,6 @@ async function executeCommand(command, input = {}) {
148
191
  } : { exitCode: 1 };
149
192
  }
150
193
  }
151
- async function captureProcessOutput(action) {
152
- const stdoutChunks = [];
153
- const stderrChunks = [];
154
- const originalStdoutWrite = process.stdout.write.bind(process.stdout);
155
- const originalStderrWrite = process.stderr.write.bind(process.stderr);
156
- const originalConsoleMethods = {
157
- log: console.log,
158
- info: console.info,
159
- debug: console.debug,
160
- warn: console.warn,
161
- error: console.error
162
- };
163
- const captureChunk = (chunks, chunk, encoding) => {
164
- if (typeof chunk === "string") {
165
- chunks.push(chunk);
166
- return;
167
- }
168
- chunks.push(Buffer.from(chunk).toString(encoding));
169
- };
170
- const captureConsole = (chunks, args) => {
171
- chunks.push(`${format(...args)}\n`);
172
- };
173
- const createWriteCapture = (chunks) => ((chunk, encoding, cb) => {
174
- captureChunk(chunks, chunk, typeof encoding === "string" ? encoding : void 0);
175
- if (typeof encoding === "function") encoding(null);
176
- else cb?.(null);
177
- return true;
178
- });
179
- process.stdout.write = createWriteCapture(stdoutChunks);
180
- process.stderr.write = createWriteCapture(stderrChunks);
181
- for (const method of [
182
- "log",
183
- "info",
184
- "debug"
185
- ]) console[method] = (...args) => captureConsole(stdoutChunks, args);
186
- for (const method of ["warn", "error"]) console[method] = (...args) => captureConsole(stderrChunks, args);
187
- try {
188
- return {
189
- ok: true,
190
- value: await action(),
191
- stdout: stdoutChunks.join(""),
192
- stderr: stderrChunks.join("")
193
- };
194
- } catch (error) {
195
- return {
196
- ok: false,
197
- error,
198
- stdout: stdoutChunks.join(""),
199
- stderr: stderrChunks.join("")
200
- };
201
- } finally {
202
- process.stdout.write = originalStdoutWrite;
203
- process.stderr.write = originalStderrWrite;
204
- Object.assign(console, originalConsoleMethods);
205
- }
206
- }
207
194
  function formatTypeHint(field) {
208
195
  return isSchemaField(field) ? "" : ` <${field.type}>`;
209
196
  }
@@ -377,9 +364,9 @@ function getOptionParseType(field) {
377
364
  }
378
365
  function buildParseArgsOptions(options) {
379
366
  const config = {};
380
- for (const field of options) config[field.name] = field.alias ? {
367
+ for (const field of options) config[field.name] = field.short ? {
381
368
  type: getOptionParseType(field),
382
- short: field.alias
369
+ short: field.short
383
370
  } : { type: getOptionParseType(field) };
384
371
  return config;
385
372
  }
@@ -395,7 +382,7 @@ function detectDuplicateOption(options, tokens) {
395
382
  }
396
383
  }
397
384
  }
398
- async function parseCommand(command, rawArgs) {
385
+ async function parseCommandArgs(command, rawArgs) {
399
386
  let parsed;
400
387
  try {
401
388
  parsed = parseArgs({
@@ -442,4 +429,4 @@ async function parseCommand(command, rawArgs) {
442
429
  };
443
430
  }
444
431
  //#endregion
445
- export { isSchemaField as a, isDefinedCommand as i, defineCommand as n, parseCommand as o, executeCommand as r, captureProcessOutput as t };
432
+ export { isSchemaField as a, isDefinedCommand as i, defineGroup as n, parseCommandArgs as o, executeCommand as r, defineCommand as t };