diff --git a/packages/cli-parser/bun.lockb b/packages/cli-parser/bun.lockb new file mode 100755 index 00000000..249be439 Binary files /dev/null and b/packages/cli-parser/bun.lockb differ diff --git a/packages/cli-parser/cli-builder.type.ts b/packages/cli-parser/cli-builder.type.ts index 8cd2bd30..f1f7b2c2 100644 --- a/packages/cli-parser/cli-builder.type.ts +++ b/packages/cli-parser/cli-builder.type.ts @@ -4,5 +4,7 @@ export interface CliParameter { positioned?: boolean; // Whether the argument needs a value (requires positioned to be false) needsValue?: boolean; + optional?: true; type: "string" | "number" | "boolean" | "array"; + description?: string; } diff --git a/packages/cli-parser/index.ts b/packages/cli-parser/index.ts index 34c31177..4e47ce30 100644 --- a/packages/cli-parser/index.ts +++ b/packages/cli-parser/index.ts @@ -1,4 +1,5 @@ import type { CliParameter } from "./cli-builder.type"; +import chalk from "chalk"; export function startsWithArray(fullArray: any[], startArray: any[]) { if (startArray.length > fullArray.length) { @@ -9,6 +10,10 @@ export function startsWithArray(fullArray: any[], startArray: any[]) { .every((value, index) => value === startArray[index]); } +interface TreeType { + [key: string]: CliCommand | TreeType; +} + /** * Builder for a CLI * @param commands Array of commands to register @@ -115,6 +120,53 @@ export class CliBuilder { command.run(argsWithoutCategories); } + + /** + * Recursively urns the commands into a tree where subcategories mark each sub-branch + * @example + * ```txt + * user verify + * user delete + * user new admin + * user new + * -> + * user + * verify + * delete + * new + * admin + * "" + * ``` + */ + getCommandTree(commands: CliCommand[]): TreeType { + const tree: TreeType = {}; + + for (const command of commands) { + let currentLevel = tree; // Start at the root + + // Split the command into parts and iterate over them + for (const part of command.categories) { + // If this part doesn't exist in the current level of the tree, add it + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!currentLevel[part]) { + // If this is the last part of the command, add the command itself + if ( + part === + command.categories[command.categories.length - 1] + ) { + currentLevel[part] = command; + break; + } + currentLevel[part] = {}; + } + + // Move down to the next level of the tree + currentLevel = currentLevel[part] as TreeType; + } + } + + return tree; + } } /** @@ -125,9 +177,45 @@ export class CliCommand { constructor( public categories: string[], public argTypes: CliParameter[], - private execute: (args: Record) => void + private execute: (args: Record) => void, + public description?: string, + public example?: string ) {} + /** + * Display help message for the command + * formatted with Chalk and with emojis + */ + displayHelp() { + const positionedArgs = this.argTypes.filter(arg => arg.positioned); + const unpositionedArgs = this.argTypes.filter(arg => !arg.positioned); + const helpMessage = ` +${chalk.green("📚 Command:")} ${chalk.yellow(this.categories.join(" "))} +${this.description ? `${chalk.cyan(this.description)}\n` : ""} +${chalk.magenta("🔧 Arguments:")} +${unpositionedArgs + .map( + arg => + `${chalk.bold(arg.name)}: ${chalk.blue(arg.description ?? "(no description)")} ${ + arg.optional ? chalk.gray("(optional)") : "" + }` + ) + .join("\n")} +${positionedArgs + .map( + arg => + `--${chalk.bold(arg.name)}: ${chalk.blue(arg.description ?? "(no description)")} ${ + arg.optional ? chalk.gray("(optional)") : "" + }` + ) + .join( + "\n" + )}${this.example ? `\n${chalk.magenta("🚀 Example:")}\n${chalk.bgGray(this.example)}` : ""} +`; + + console.log(helpMessage); + } + /** * Parses string array arguments into a full JavaScript object * @param argsWithoutCategories @@ -201,3 +289,35 @@ export class CliCommand { this.execute(args); } } + +const cliBuilder = new CliBuilder(); + +const cliCommand = new CliCommand( + ["category1", "category2"], + [ + { + name: "name", + type: "string", + needsValue: true, + description: "Name of new item", + }, + { + name: "delete-previous", + type: "number", + needsValue: false, + positioned: true, + optional: true, + description: "Also delete the previous item", + }, + { name: "arg3", type: "boolean", needsValue: false }, + { name: "arg4", type: "array", needsValue: true }, + ], + () => { + // Do nothing + }, + "I love sussy sauces", + "emoji add --url https://site.com/image.png" +); + +cliBuilder.registerCommand(cliCommand); +//cliBuilder.displayHelp(); diff --git a/packages/cli-parser/package.json b/packages/cli-parser/package.json index bf582902..acc2e3ee 100644 --- a/packages/cli-parser/package.json +++ b/packages/cli-parser/package.json @@ -1,6 +1,6 @@ { - "name": "arg-parser", - "version": "0.0.0", - "main": "index.ts", - "dependencies": {} + "name": "arg-parser", + "version": "0.0.0", + "main": "index.ts", + "dependencies": { "strip-ansi": "^7.1.0" } } \ No newline at end of file diff --git a/packages/cli-parser/tests/cli-builder.test.ts b/packages/cli-parser/tests/cli-builder.test.ts index 8c5b3f5c..15cba848 100644 --- a/packages/cli-parser/tests/cli-builder.test.ts +++ b/packages/cli-parser/tests/cli-builder.test.ts @@ -1,6 +1,7 @@ // FILEPATH: /home/jessew/Dev/lysand/packages/cli-parser/index.test.ts import { CliCommand, CliBuilder, startsWithArray } from ".."; -import { describe, beforeEach, it, expect, jest } from "bun:test"; +import { describe, beforeEach, it, expect, jest, spyOn } from "bun:test"; +import stripAnsi from "strip-ansi"; describe("startsWithArray", () => { it("should return true when fullArray starts with startArray", () => { @@ -141,6 +142,70 @@ describe("CliCommand", () => { arg5: "value5", }); }); + + it("should display help message correctly", () => { + const consoleLogSpy = spyOn(console, "log").mockImplementation(() => { + // Do nothing + }); + + cliCommand = new CliCommand( + ["category1", "category2"], + [ + { + name: "arg1", + type: "string", + needsValue: true, + description: "Argument 1", + optional: true, + }, + { + name: "arg2", + type: "number", + needsValue: true, + description: "Argument 2", + }, + { + name: "arg3", + type: "boolean", + needsValue: false, + description: "Argument 3", + optional: true, + positioned: true, + }, + { + name: "arg4", + type: "array", + needsValue: true, + description: "Argument 4", + positioned: true, + }, + ], + () => { + // Do nothing + }, + "This is a test command", + "category1 category2 --arg1 value1 --arg2 42 arg3 --arg4 value1,value2" + ); + + cliCommand.displayHelp(); + + const loggedString = consoleLogSpy.mock.calls.map(call => + stripAnsi(call[0]) + )[0]; + + consoleLogSpy.mockRestore(); + + expect(loggedString).toContain("📚 Command: category1 category2"); + expect(loggedString).toContain("🔧 Arguments:"); + expect(loggedString).toContain("arg1: Argument 1 (optional)"); + expect(loggedString).toContain("arg2: Argument 2"); + expect(loggedString).toContain("--arg3: Argument 3 (optional)"); + expect(loggedString).toContain("--arg4: Argument 4"); + expect(loggedString).toContain("🚀 Example:"); + expect(loggedString).toContain( + "category1 category2 --arg1 value1 --arg2 42 arg3 --arg4 value1,value2" + ); + }); }); describe("CliBuilder", () => { @@ -214,4 +279,64 @@ describe("CliBuilder", () => { arg1: "value1", }); }); + + describe("should build command tree", () => { + let cliBuilder: CliBuilder; + let mockCommand1: CliCommand; + let mockCommand2: CliCommand; + let mockCommand3: CliCommand; + let mockCommand4: CliCommand; + let mockCommand5: CliCommand; + + beforeEach(() => { + mockCommand1 = new CliCommand(["user", "verify"], [], jest.fn()); + mockCommand2 = new CliCommand(["user", "delete"], [], jest.fn()); + mockCommand3 = new CliCommand( + ["user", "new", "admin"], + [], + jest.fn() + ); + mockCommand4 = new CliCommand(["user", "new"], [], jest.fn()); + mockCommand5 = new CliCommand(["admin", "delete"], [], jest.fn()); + cliBuilder = new CliBuilder([ + mockCommand1, + mockCommand2, + mockCommand3, + mockCommand4, + mockCommand5, + ]); + }); + + it("should build the command tree correctly", () => { + const tree = cliBuilder.getCommandTree(cliBuilder.commands); + expect(tree).toEqual({ + user: { + verify: mockCommand1, + delete: mockCommand2, + new: { + admin: mockCommand3, + }, + }, + admin: { + delete: mockCommand5, + }, + }); + }); + + it("should build the command tree correctly when there are no commands", () => { + cliBuilder = new CliBuilder([]); + const tree = cliBuilder.getCommandTree(cliBuilder.commands); + expect(tree).toEqual({}); + }); + + it("should build the command tree correctly when there is only one command", () => { + cliBuilder = new CliBuilder([mockCommand1]); + const tree = cliBuilder.getCommandTree(cliBuilder.commands); + expect(tree).toEqual({ + user: { + verify: mockCommand1, + }, + }); + }); + }); });