Initial commit

Includes first version of Bridagier command parser
This commit is contained in:
Eric Rumsey 2025-05-14 16:22:35 -05:00
commit 825fe82168
13 changed files with 558 additions and 0 deletions

34
.gitignore vendored Normal file
View File

@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

15
README.md Normal file
View File

@ -0,0 +1,15 @@
# cli-framework
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run index.ts
```
This project was created using `bun init` in bun v1.2.9. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.

76
bun.lock Normal file
View File

@ -0,0 +1,76 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "cli-framework",
"devDependencies": {
"@maxmorozoff/try-catch-tuple": "^0.1.2",
"@maxmorozoff/try-catch-tuple-ts-plugin": "^0.0.1",
"@types/bun": "latest",
"ts-patch": "^3.3.0",
},
"peerDependencies": {
"typescript": "^5.8.3",
},
},
},
"packages": {
"@maxmorozoff/try-catch-tuple": ["@maxmorozoff/try-catch-tuple@0.1.2", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-stFwucLpAkJPkLIlBGsXB/Q9A3kSTFoioqqlz7V5r6pwsysQ1/+vpVKEutmbjSztoIOdy+iTn/iSSKyJXb+ebQ=="],
"@maxmorozoff/try-catch-tuple-ts-plugin": ["@maxmorozoff/try-catch-tuple-ts-plugin@0.0.1", "", { "peerDependencies": { "ts-patch": "^3.3.0", "typescript": "^5.0.0" }, "optionalPeers": ["ts-patch"] }, "sha512-OeQpI8YfkuB1gFXSCyogsllfoMmEYnZvIRsSV+MWOxk/amrpA4rKjpbmqaHpVVo3YKFLqTLB8RLDFgVkoWBGhQ=="],
"@types/bun": ["@types/bun@1.2.13", "", { "dependencies": { "bun-types": "1.2.13" } }, "sha512-u6vXep/i9VBxoJl3GjZsl/BFIsvML8DfVDO0RYLEwtSZSp981kEO1V5NwRcO1CPJ7AmvpbnDCiMKo3JvbDEjAg=="],
"@types/node": ["@types/node@22.14.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"bun-types": ["bun-types@1.2.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-rRjA1T6n7wto4gxhAO/ErZEtOXyEZEmnIHQfl0Dt1QQSB4QV0iP6BZ9/YB5fZaHFQ2dwHFrmPaRQ9GGMX01k9Q=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"global-prefix": ["global-prefix@4.0.0", "", { "dependencies": { "ini": "^4.1.3", "kind-of": "^6.0.3", "which": "^4.0.0" } }, "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"ini": ["ini@4.1.3", "", {}, "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="],
"kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
"semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"ts-patch": ["ts-patch@3.3.0", "", { "dependencies": { "chalk": "^4.1.2", "global-prefix": "^4.0.0", "minimist": "^1.2.8", "resolve": "^1.22.2", "semver": "^7.6.3", "strip-ansi": "^6.0.1" }, "bin": { "ts-patch": "bin/ts-patch.js", "tspc": "bin/tspc.js" } }, "sha512-zAOzDnd5qsfEnjd9IGy1IRuvA7ygyyxxdxesbhMdutt8AHFjD8Vw8hU2rMF89HX1BKRWFYqKHrO8Q6lw0NeUZg=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
}
}

1
index.ts Normal file
View File

@ -0,0 +1 @@
console.log("Hello via Bun!");

15
package.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "cli-framework",
"module": "index.ts",
"type": "module",
"private": true,
"devDependencies": {
"@maxmorozoff/try-catch-tuple": "^0.1.2",
"@maxmorozoff/try-catch-tuple-ts-plugin": "^0.0.1",
"@types/bun": "latest",
"ts-patch": "^3.3.0"
},
"peerDependencies": {
"typescript": "^5.8.3"
}
}

View File

@ -0,0 +1,88 @@
import { type ParseArgsOptionsConfig } from 'util'
import type { BrigadierTreeCommand, BrigadierTree } from "./types";
const commandObj:
{ _opts?: ParseArgsOptionsConfig } &
Record<string, BrigadierTreeCommand> = {}
commandObj._opts = {
verbose: {
type: "boolean",
short: 'v',
},
silent: {
type: "boolean",
short: "s",
},
}
commandObj.run = {
command: "run",
options: {
speed: {
type: "string",
}
}
}
commandObj.walk = {
command: "walk",
options: {
fancy: {
type: "boolean",
short: "f"
}
}
}
commandObj.stand = {
command: "stand",
}
commandObj.sit = {
command: "sit",
}
commandObj.say = {
command: "say",
value: {
required: true,
inputType: "string",
dataType: "string[]",
},
options: {
accent: {
type: 'string',
default: "American",
}
},
subcommands: [
{
command: "talk",
value: {
required: true,
inputType: "string",
dataType: "string[]"
},
},
{
command: "whisper",
value: {
required: true,
inputType: "string",
dataType: "string[]"
}
},
{
command: "yell",
value: {
required: true,
inputType: "string",
dataType: "string[]"
}
}
]
}
const { _opts, ...commands } = commandObj
export const main: BrigadierTree = { _opts, commands: [...Object.values(commands)] }

View File

@ -0,0 +1,82 @@
export class BrigadierInternalError extends Error {
constructor(error: Error | undefined = undefined) {
super()
this.name = this.constructor.name;
this.message = "The argument parser experience an internal error."
if (error) this.cause = error
}
}
export class BrigadierConfigError extends Error {
constructor(message: string, error: Error | undefined = undefined) {
super(message)
this.name = this.constructor.name;
if (error) this.cause = error
}
}
export class BrigadierOptionError extends Error {
#defaultCode = "ERR_PARSE_ARGS_BRIGADIER_PARSE_OPTION";
#defaultMessages = {
helpPrimitive: `Run '--help' for more information on how to use this command.`,
get generic() {
return `There was an error processing on of the given options. ${this.helpPrimitive}`;
},
get unknownOption() {
return `Unknown option. ${this.helpPrimitive}`;
},
get invalidOptionValue() {
return `An option has an invalid value. ${this.helpPrimitive}`;
},
get missingOptionValue() {
return `An option value is missing. ${this.helpPrimitive}`;
},
};
option: string | undefined = undefined;
code: string = this.#defaultCode;
constructor(message: string, error: Error) {
super(message);
if ("code" in error && typeof error.code === "string")
this.#switchOnCodes(error.code);
this.name = this.constructor.name;
if (error) this.cause = error;
}
#switchOnCodes(code: string) {
if (code === "ERR_PARSE_ARGS_UNKNOWN_OPTION") {
this.message = this.#unknownOption(this.message);
this.code = code;
return;
}
if (code === "ERR_PARSE_ARGS_INVALID_OPTION_VALUE") {
this.message = this.#invalidOptionValue(this.message);
this.code = code;
return;
}
}
#unknownOption(message: string) {
const index = 18;
if (typeof message !== "string" || message[index - 3] !== "'")
return this.#defaultMessages.unknownOption;
this.option = this.#extractOptionString(message, index, ". ");
return `Unknown option '--${this.option}'. ${this.#defaultMessages.helpPrimitive}`;
}
#invalidOptionValue(message: string) {
const index = 10;
if (typeof message !== "string" || message[index - 3] !== "'")
return this.#defaultMessages.invalidOptionValue;
this.option = this.#extractOptionString(message, index, "<value>' ");
return `Option '--${this.option}' has an invalid value. ${this.#defaultMessages.helpPrimitive}`;
}
#extractOptionString(message: string, index: number, testString: string) {
return message.slice(index, message.indexOf(testString) - 1);
}
}

View File

@ -0,0 +1,2 @@
export { main as parser } from "./parser";
export { treeUtils as tree } from "./tree"

139
src/lib/brigadier/parser.ts Normal file
View File

@ -0,0 +1,139 @@
import { tryCatch } from "@maxmorozoff/try-catch-tuple";
import {
parseArgs,
type ParseArgsConfig,
type ParseArgsOptionsConfig,
} from "util";
import { assertArrayOfStrings } from "../util/typeCheck";
import { BrigadierConfigError, BrigadierInternalError, BrigadierOptionError } from "./errors";
import type { BrigadierTree, BrigadierParserOverrides, BrigadierTreeCommand, BrigadierOutput } from "./types";
import { treeUtils } from "./tree";
const defaultOverrides: BrigadierParserOverrides = {
allowPositionals: true,
allowNegative: true,
tokens: false,
strict: true,
};
function findNextTree(tree: Array<BrigadierTreeCommand>, command: string) {
const nextCommand = tree.filter((obj) => obj.command === command)
return nextCommand[0]?.subcommands
}
function findCommands(tree: Array<BrigadierTreeCommand> | undefined, args: Array<string>, out: Array<string> = []) {
const item = args[0]
if (!item || !tree) return out
const [commands, commandError] = tryCatch(() => treeUtils.commandArray(tree))
if (commandError) throw commandError
if (item.slice(0, 0) === "-" || !commands.includes(item)) return out
const [newTree, newTreeError] = tryCatch(() => findNextTree(tree, item))
if (newTreeError) throw newTreeError
const newArgs = args.slice(1)
const newOut = [...out, item]
return findCommands(newTree, newArgs, newOut)
}
function findOpts(tree: Array<BrigadierTreeCommand>, commands: Array<string>, opts: ParseArgsOptionsConfig = {}) {
const item = commands[0]
if (!item) throw new TypeError("The first item in the array is missing.")
const commandObj = tree.filter((obj) => obj.command === item)
const thisOpts = commandObj[0]?.options ? commandObj[0].options : {}
const nextOpts = Object.assign(opts, thisOpts)
if (commands.length === 1) {
const config = nextOpts
return config
}
const nextTree = commandObj[0]?.subcommands
if (!nextTree) throw new BrigadierConfigError(`The '${item}' command was expected to have subcommands.`)
return findOpts(nextTree, commands.slice(1), nextOpts)
}
function setParsedArgsConfig(
args: Array<string>,
options: ParseArgsOptionsConfig,
overrides: BrigadierParserOverrides | undefined,
commands: Array<string>
) {
if (!assertArrayOfStrings(args))
throw new TypeError(`Parameter "args" must be an array of strings`);
const obj =
overrides === undefined
? defaultOverrides
: Object.assign({}, defaultOverrides, overrides);
const configObj: ParseArgsConfig = {
args,
options,
...obj,
};
return { config: configObj, commands: commands };
}
function generateParsedArgsConfig(tree: BrigadierTree, args: Array<string>, overrides: BrigadierParserOverrides | undefined = undefined) {
const [commandsArray, commandsError] = tryCatch(() => findCommands(tree.commands, args.slice(2)))
if (commandsError) throw commandsError
if (!commandsArray[0]) {
const opts = tree._opts
return setParsedArgsConfig(args, opts, overrides, commandsArray)
}
const [commandOpts, commandOptsError] = tryCatch(() => findOpts(tree.commands, commandsArray))
if (commandOptsError) throw commandOptsError
const opts = Object.assign({}, tree._opts, commandOpts)
return setParsedArgsConfig(args, opts, overrides, commandsArray)
}
function getParsedArgs(opts: { config: ParseArgsConfig, commands: Array<string> }) {
const [result, error] = tryCatch(() => {
return parseArgs(opts.config);
});
if (error) throw new BrigadierOptionError(error.message, error);
return { ...result, commands: opts.commands };
}
function buildPositionalsArray(commands: Array<string>, positionals: Array<string>) {
const testSet = new Set(commands)
return positionals.filter(str => !testSet.has(str))
}
function handleErr(error: Error) {
if (error instanceof BrigadierOptionError || error instanceof BrigadierConfigError) return error
return new BrigadierInternalError()
}
function buildOutputObject(tree: BrigadierTree, args: Array<string>, overrides: BrigadierParserOverrides | undefined = undefined) {
try {
const [config, configError] = tryCatch(() => generateParsedArgsConfig(tree, args, overrides))
if (configError) throw configError
const [parsedArgs, parsedError] = tryCatch(() => getParsedArgs(config))
if (parsedError) throw parsedError
const programPaths = {
bun: parsedArgs.positionals[0] ? parsedArgs.positionals[0] : "",
path: parsedArgs.positionals[1] ? parsedArgs.positionals[1] : "",
}
const command = config.commands[0] ? { command: config.commands[0] } : {}
const subcommands = config.commands[1] ? { subcommands: config.commands.slice(1) } : {}
const [positionals, positionalsError] = tryCatch(() => buildPositionalsArray(config.commands, parsedArgs.positionals.slice(2)))
if (positionalsError) throw positionalsError
const positionalsObj = positionals.length > 0 ? { positionals } : {}
const values = parsedArgs.values ? parsedArgs.values : {}
const out: BrigadierOutput = Object.assign({}, programPaths, command, subcommands, positionalsObj, { values })
return out
} catch (error) {
throw handleErr(error as Error)
}
}
export const main = buildOutputObject;

13
src/lib/brigadier/tree.ts Normal file
View File

@ -0,0 +1,13 @@
import { demoTree } from ".";
import type { BrigadierTreeCommand } from "./types";
function commandArray(array: Array<BrigadierTreeCommand>) {
const out: Array<string> = []
array.forEach((obj) => out.push(obj.command))
return out
}
export const treeUtils = {
commandArray
}

View File

@ -0,0 +1,49 @@
import { type ParseArgsOptionsConfig } from 'util'
export type BrigadierCommandValue =
"string" |
"number" |
"boolean" |
"string[]" |
"number[]" |
"boolean[]" |
"mixed[]"
export type BrigadierTreeCommand = {
command: string,
value?: {
required: boolean,
inputType: "string" | "boolean",
dataType: BrigadierCommandValue,
default?: string | boolean
}
options?: ParseArgsOptionsConfig,
subcommands?: Array<BrigadierTreeCommand>
}
export type BrigadierTree = {
_opts: ParseArgsOptionsConfig,
commands: Array<BrigadierTreeCommand>
}
export type BrigadierOutput = {
bun: string,
path: string,
command?: string,
subcommands?: Array<string>,
positionals?: Array<string>,
values?: Record<string, string | boolean>
}
export type BrigadierParserOverrides = {
strict?: boolean;
allowPositionals?: boolean;
allowNegative?: boolean;
tokens?: boolean;
};
export type BrigadierInput = BrigadierTree & {
args: Array<string>
overrides?: BrigadierParserOverrides;
};

View File

@ -0,0 +1,6 @@
export function assertArrayOfStrings(arr: unknown) {
const arrName = Object.keys({ arr })[0];
if (!Array.isArray(arr)) return false;
if (!arr.every((item: unknown) => typeof item === "string")) return false;
return true;
}

38
tsconfig.json Normal file
View File

@ -0,0 +1,38 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
// Plugins
"plugins": [
{
"name": "@maxmorozoff/try-catch-tuple-ts-plugin",
// --- Optional Configuration for LSP ---
"errorLevel": "error",
"allowIgnoredError": true,
"checkWrappedCalls": true}
]
}
}